diff --git a/.editorconfig b/.editorconfig index 96036c1..a5acc34 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,5 @@ # SolSharp - editorconfig -# Modern C# only. The meaningful modernizers are warnings, not hints, so legacy +# Modern C# 12. The meaningful modernizers are warnings, not hints, so legacy # patterns surface on build (EnforceCodeStyleInBuild is set in Directory.Build.props). root = true @@ -129,6 +129,48 @@ csharp_indent_switch_labels = true csharp_space_around_binary_operators = before_and_after csharp_blank_lines_around_member = 1 +# One explicit modifier order for Rider, Roslyn/dotnet format, and StyleCop SA1206. +# In particular: `static readonly` and `override async`, never the inverse. +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:warning +dotnet_diagnostic.IDE0036.severity = warning + +# StyleCop defaults are build-gated. Disable only rules that conflict with the +# established repository conventions or impose legacy boilerplate/layout. + +# Repository conventions: no `this.` qualification, `_camelCase` private fields, +# optional braces for single-line statements, and no mandatory file headers. +dotnet_diagnostic.SA1101.severity = none +dotnet_diagnostic.SA1309.severity = none +dotnet_diagnostic.SA1503.severity = none +dotnet_diagnostic.SA1519.severity = none +dotnet_diagnostic.SA1520.severity = none +dotnet_diagnostic.SA1633.severity = none + +# CS1591 build-gates the presence of XML documentation on production public API. +# Completeness is a review policy; StyleCop's fixed wording/internal-element rules +# conflict with the repository's concise contract style and are disabled explicitly. +dotnet_diagnostic.SA1600.severity = none +dotnet_diagnostic.SA1601.severity = none +dotnet_diagnostic.SA1611.severity = none +dotnet_diagnostic.SA1615.severity = none +dotnet_diagnostic.SA1623.severity = none +dotnet_diagnostic.SA1642.severity = none +dotnet_diagnostic.SA1643.severity = none + +# Preserve the existing compact, domain-oriented file/member layout. These are +# stylistic preferences rather than correctness or readability diagnostics. +dotnet_diagnostic.SA1122.severity = none +dotnet_diagnostic.SA1128.severity = none +dotnet_diagnostic.SA1201.severity = none +dotnet_diagnostic.SA1202.severity = none +dotnet_diagnostic.SA1203.severity = none +dotnet_diagnostic.SA1204.severity = none +dotnet_diagnostic.SA1214.severity = none +dotnet_diagnostic.SA1402.severity = none +dotnet_diagnostic.SA1413.severity = none +dotnet_diagnostic.SA1515.severity = none +dotnet_diagnostic.SA1649.severity = none + # ── Naming ─────────────────────────────────────────────────────────────────── # Interfaces: IPascalCase dotnet_naming_rule.interfaces_i_prefix.severity = warning diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4a3b2b1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 +updates: + - package-ecosystem: nuget + directories: + - / + - /benchmarks/SolSharp.Benchmarks + schedule: + interval: weekly + day: monday + time: "04:00" + timezone: Etc/UTC + open-pull-requests-limit: 10 + commit-message: + prefix: deps + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "04:30" + timezone: Etc/UTC + open-pull-requests-limit: 10 + commit-message: + prefix: deps diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d937f..872ecc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,45 +6,194 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + jobs: build-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up .NET 8 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 8.0.x + # setup-dotnet proves installation, while global.json controls selection. Assert the actual + # resolver result because hosted runners also have newer SDK majors preinstalled. + - name: Verify .NET 8 SDK selection + shell: bash + run: | + selected_version="$(dotnet --version)" + if [[ "${selected_version}" != 8.* ]]; then + echo "Expected a .NET 8 SDK, but dotnet selected ${selected_version}." + dotnet --info + exit 1 + fi + - name: Restore - run: dotnet restore + run: dotnet restore -p:NuGetAuditMode=all -warnaserror # Hard gate: the project keeps a zero-warning build, so fail CI on any warning. - name: Build run: dotnet build --no-restore --configuration Release -warnaserror - # The SolSharp.IntegrationTests suite hits a live cluster, so it is excluded here to keep CI - # deterministic and offline; run it locally with a configured SOLSHARP_RPC_URL. - - name: Test - run: dotnet test --no-build --configuration Release --filter "TestCategory!=Integration" + - name: Verify formatting and analyzer style + run: dotnet format --no-restore --verify-no-changes --severity info + + # The four unit-test projects produce overlapping reports because higher layers reference + # lower assemblies. ReportGenerator merges those hits before enforcing the repository-wide + # line threshold, while compiler/source-generated files remain outside the metric. + - name: Test and collect coverage + shell: bash + env: + COVERAGE_ROOT: ${{ runner.temp }}/solsharp-coverage + REPORTGENERATOR_ROOT: ${{ runner.temp }}/solsharp-reportgenerator + run: | + unit_projects=( + tests/SolSharp.Core.Tests/SolSharp.Core.Tests.csproj + tests/SolSharp.Rpc.Tests/SolSharp.Rpc.Tests.csproj + tests/SolSharp.Wallet.Tests/SolSharp.Wallet.Tests.csproj + tests/SolSharp.Programs.Tests/SolSharp.Programs.Tests.csproj + ) + + for project in "${unit_projects[@]}"; do + project_name="$(basename "${project}" .csproj)" + dotnet test "${project}" \ + --no-build \ + --configuration Release \ + --collect:"XPlat Code Coverage" \ + --settings coverage.runsettings \ + --results-directory "${COVERAGE_ROOT}/${project_name}" + done + + # Keep the deterministic, non-live integration fixtures in ordinary CI as well. + dotnet test tests/SolSharp.IntegrationTests/SolSharp.IntegrationTests.csproj \ + --no-build \ + --configuration Release \ + --filter "TestCategory!=Integration" + + dotnet tool install dotnet-reportgenerator-globaltool \ + --tool-path "${REPORTGENERATOR_ROOT}" \ + --version 5.5.11 + + "${REPORTGENERATOR_ROOT}/reportgenerator" \ + "-reports:${COVERAGE_ROOT}/**/coverage.cobertura.xml" \ + "-targetdir:${COVERAGE_ROOT}/report" \ + "-reporttypes:Cobertura;MarkdownSummaryGithub" \ + "-assemblyfilters:+SolSharp.Core;+SolSharp.Rpc;+SolSharp.Wallet;+SolSharp.Programs" \ + "-filefilters:-*/obj/*;-*.g.cs" \ + "-verbosity:Warning" + + cat "${COVERAGE_ROOT}/report/SummaryGithub.md" >> "${GITHUB_STEP_SUMMARY}" + line_rate="$(sed -n 's/.*= 0.90) }'; then + echo "Line coverage ${line_percent_exact}% is below the required 90.0%." + exit 1 + fi + + # Exact hit counts can vary by a few environment-dependent lines. Keep the documented + # measurement conservative: it may trail the current result, but it must never overstate it. + if ! awk -v rate="${line_rate}" -v advertised="${advertised_percent}" \ + 'BEGIN { exit !(rate * 100 >= advertised) }'; then + echo "Line coverage ${line_percent_exact}% is below the documented ${advertised_percent}%." + exit 1 + fi - # Proves the Native AOT claim end to end: the sample publishes with PublishAot (trimmed, no JIT, - # no reflection-based serialization) and then runs its offline signing / transaction-serialization / - # JSON-RPC-pipeline checks as a native binary. A type missing from the source-generated JSON context - # or a reflection call sneaking into a hot path fails this job. + for readme in README.md README.nuget.md; do + if ! grep -Fq "unit_test_coverage-${advertised_percent}%25_line" "${readme}"; then + echo "${readme} does not advertise the ${advertised_percent}% coverage baseline." + exit 1 + fi + done + + if ! grep -Fq "**${advertised_percent}% of lines**" README.md || \ + ! grep -Fq "covers ${advertised_percent}% of hand-written production lines" README.nuget.md; then + echo "README coverage prose does not match the ${advertised_percent}% baseline." + exit 1 + fi + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-report + path: ${{ runner.temp }}/solsharp-coverage/report + if-no-files-found: warn + retention-days: 14 + + # Proves the shipped package's Native AOT claim end to end: pack first (which also validates the + # public API against the previous stable package), then consume that nupkg from the AOT sample. aot-smoke: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up .NET 8 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 8.0.x - - name: Publish sample (Native AOT) - run: dotnet publish samples/SolSharp.AotSmoke -c Release -r linux-x64 + - name: Verify .NET 8 SDK selection + shell: bash + run: | + selected_version="$(dotnet --version)" + if [[ "${selected_version}" != 8.* ]]; then + echo "Expected a .NET 8 SDK, but dotnet selected ${selected_version}." + dotnet --info + exit 1 + fi + + - name: Restore and audit dependencies + run: dotnet restore -p:NuGetAuditMode=all -warnaserror + + - name: Pack and validate public API + run: dotnet pack src/SolSharp/SolSharp.csproj -c Release -o artifacts -p:Version=999.0.0-ci --no-restore -warnaserror + + - name: Publish packed package consumer (Native AOT) + shell: bash + env: + NUGET_PACKAGES: ${{ runner.temp }}/solsharp-ci-packages + run: | + package_version="999.0.0-ci" + dotnet restore samples/SolSharp.AotSmoke \ + --runtime linux-x64 \ + -p:UsePackedSolSharp=true \ + -p:SolSharpPackageVersion="${package_version}" \ + --source artifacts \ + --source https://api.nuget.org/v3/index.json \ + -p:NuGetAuditMode=all \ + -warnaserror + + artifact_path="$(find artifacts -maxdepth 1 -iname "solsharp.${package_version}.nupkg" -print -quit)" + restored_path="${NUGET_PACKAGES}/solsharp/${package_version}/solsharp.${package_version}.nupkg" + if [[ -z "${artifact_path}" || ! -f "${restored_path}" ]] || ! cmp --silent "${artifact_path}" "${restored_path}"; then + echo "The AOT consumer did not restore the exact package artifact produced by this job." + sha256sum "${artifact_path}" "${restored_path}" 2>/dev/null || true + exit 1 + fi + + dotnet publish samples/SolSharp.AotSmoke \ + --configuration Release \ + --runtime linux-x64 \ + --no-restore \ + -warnaserror \ + -p:UsePackedSolSharp=true \ + -p:SolSharpPackageVersion="${package_version}" - name: Run sample run: samples/SolSharp.AotSmoke/bin/Release/net8.0/linux-x64/publish/SolSharp.AotSmoke diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..d54f4de --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,45 @@ +name: CodeQL + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + - cron: "29 3 * * 2" + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: analyze-csharp + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + languages: csharp + queries: security-extended + + - name: Set up .NET 8 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore -p:NuGetAuditMode=all -warnaserror + + - name: Build for CodeQL + run: dotnet build --no-restore --configuration Release -warnaserror + + - name: Analyze + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + category: /language:csharp diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2a701f..6469587 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,43 +10,183 @@ jobs: publish: runs-on: ubuntu-latest permissions: - id-token: write # required for the OIDC token exchange with NuGet + id-token: write # required for NuGet OIDC and package attestation contents: read + attestations: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false - name: Set up .NET 8 - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 8.0.x + - name: Verify .NET 8 SDK selection + shell: bash + run: | + selected_version="$(dotnet --version)" + if [[ "${selected_version}" != 8.* ]]; then + echo "Expected a .NET 8 SDK, but dotnet selected ${selected_version}." + dotnet --info + exit 1 + fi + + - name: Restore and audit dependencies + run: dotnet restore -p:NuGetAuditMode=all -warnaserror + + - name: Validate tag matches package version + shell: bash + run: | + package_version="$(dotnet msbuild src/SolSharp/SolSharp.csproj -getProperty:Version -nologo)" + expected_tag="v${package_version}" + if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then + echo "Release tag ${GITHUB_REF_NAME} does not match package version ${package_version}." + exit 1 + fi + + - name: Require the tagged commit to be merged into main + shell: bash + run: | + git fetch --no-tags origin main + if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then + echo "Release commit ${GITHUB_SHA} is not contained in origin/main." + exit 1 + fi + - name: Build - run: dotnet build --configuration Release -warnaserror + run: dotnet build --configuration Release --no-restore -warnaserror + + - name: Verify formatting and analyzer style + run: dotnet format --no-restore --verify-no-changes --severity info + + - name: Require private integration endpoints + shell: bash + env: + SOLSHARP_RPC_URL: ${{ secrets.SOLSHARP_RPC_URL }} + SOLSHARP_WS_URL: ${{ secrets.SOLSHARP_WS_URL }} + SOLSHARP_DEVNET_RPC_URL: ${{ secrets.SOLSHARP_DEVNET_RPC_URL }} + run: | + for variable in SOLSHARP_RPC_URL SOLSHARP_WS_URL SOLSHARP_DEVNET_RPC_URL; do + value="${!variable:-}" + if [[ -z "${value}" ]]; then + echo "Required release integration secret ${variable} is not configured." + exit 1 + fi + + parsed_endpoint="$(python3 -c ' + import sys + from urllib.parse import urlsplit - # Unlike CI (which stays offline), the release gate runs the FULL suite, including the live-cluster - # integration tests, so nothing is published without the real read/streaming paths passing - and the - # write path (airdrop, transfer, durable nonce) against devnet. Rate limits report inconclusive (not - # failed), so a busy node will not block a release; a real regression will. Set SOLSHARP_RPC_URL / - # SOLSHARP_WS_URL secrets for a private mainnet node, SOLSHARP_DEVNET_RPC_URL for a private devnet one. + try: + endpoint = urlsplit(sys.argv[1]) + _ = endpoint.port + except ValueError as error: + raise SystemExit(f"Invalid endpoint URI: {error}") from error + + if not endpoint.scheme or not endpoint.hostname: + raise SystemExit("Endpoint URI must include a scheme and hostname.") + + print(endpoint.scheme.lower(), endpoint.hostname.rstrip(".").lower()) + ' "${value}")" || exit 1 + read -r endpoint_scheme endpoint_host <<< "${parsed_endpoint}" + case "${variable}:${endpoint_scheme}" in + SOLSHARP_RPC_URL:http|SOLSHARP_RPC_URL:https|SOLSHARP_DEVNET_RPC_URL:http|SOLSHARP_DEVNET_RPC_URL:https|SOLSHARP_WS_URL:ws|SOLSHARP_WS_URL:wss) + ;; + *) + echo "Release integration secret ${variable} has an unsupported URI scheme." + exit 1 + ;; + esac + case "${endpoint_host}" in + api.mainnet-beta.solana.com|api.devnet.solana.com|api.testnet.solana.com) + echo "Release integration secret ${variable} must use a private endpoint, not a public fallback." + exit 1 + ;; + esac + done + + # Unlike ordinary local runs, release integration is strict: transport failures, rate limits, and + # every inconclusive/skipped result fail the job. The required private endpoints keep that gate stable. - name: Test (full suite, including live integration) - run: dotnet test --no-build --configuration Release + run: dotnet test --no-build --configuration Release --logger trx env: SOLSHARP_RPC_URL: ${{ secrets.SOLSHARP_RPC_URL }} SOLSHARP_WS_URL: ${{ secrets.SOLSHARP_WS_URL }} SOLSHARP_DEVNET_RPC_URL: ${{ secrets.SOLSHARP_DEVNET_RPC_URL }} + SOLSHARP_INTEGRATION_STRICT: 'true' + + - name: Reject inconclusive or skipped tests + shell: bash + run: | + mapfile -t result_files < <(find . -type f -path '*/TestResults/*.trx') + if (( ${#result_files[@]} == 0 )); then + echo "No TRX test results were produced." + exit 1 + fi + + if grep --with-filename --extended-regexp \ + 'outcome="(NotExecuted|Skipped|Inconclusive|NotRunnable|Warning)"' "${result_files[@]}"; then + echo "Release tests contained an inconclusive or skipped result." + exit 1 + fi # No --no-build: packing rebuilds so the facade's bundling target (which folds the four # assemblies into the single SolSharp package) runs against fully resolved references. - name: Pack - run: dotnet pack src/SolSharp/SolSharp.csproj --configuration Release --output ./artifacts + run: dotnet pack src/SolSharp/SolSharp.csproj --configuration Release --output ./artifacts --no-restore -warnaserror + + # Exercise the exact artifact about to be published, including bundled assemblies and the native + # BLS backend. This catches package-only dependency/RID regressions that a project-reference build + # cannot see. + - name: Publish packed package consumer (Native AOT) + shell: bash + env: + NUGET_PACKAGES: ${{ runner.temp }}/solsharp-release-packages + run: | + package_version="${GITHUB_REF_NAME#v}" + dotnet restore samples/SolSharp.AotSmoke \ + --runtime linux-x64 \ + -p:UsePackedSolSharp=true \ + -p:SolSharpPackageVersion="${package_version}" \ + --source ./artifacts \ + --source https://api.nuget.org/v3/index.json \ + -p:NuGetAuditMode=all \ + -warnaserror + + artifact_path="$(find ./artifacts -maxdepth 1 -iname "solsharp.${package_version}.nupkg" -print -quit)" + restored_path="${NUGET_PACKAGES}/solsharp/${package_version}/solsharp.${package_version}.nupkg" + if [[ -z "${artifact_path}" || ! -f "${restored_path}" ]] || ! cmp --silent "${artifact_path}" "${restored_path}"; then + echo "The AOT consumer did not restore the exact package artifact produced by this release." + sha256sum "${artifact_path}" "${restored_path}" 2>/dev/null || true + exit 1 + fi + + dotnet publish samples/SolSharp.AotSmoke \ + --configuration Release \ + --runtime linux-x64 \ + --no-restore \ + -warnaserror \ + -p:UsePackedSolSharp=true \ + -p:SolSharpPackageVersion="${package_version}" + + - name: Run packed package consumer + run: samples/SolSharp.AotSmoke/bin/Release/net8.0/linux-x64/publish/SolSharp.AotSmoke + + - name: Attest package provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ./artifacts/*.nupkg # Trusted Publishing: exchange the GitHub OIDC token for a short-lived NuGet API key # (the jecacs/SolSharp policy authorizes this repo + this workflow file), then push with it. - name: NuGet login (OIDC) - uses: NuGet/login@v1 + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 id: login with: user: jecacs - name: Push to NuGet - run: dotnet nuget push "./artifacts/*.nupkg" --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate + run: dotnet nuget push "./artifacts/*.nupkg" --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..6c86f75 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,45 @@ +name: OpenSSF Scorecard + +on: + push: + branches: [ main ] + schedule: + - cron: "41 5 * * 1" + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: scorecard + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run OpenSSF Scorecard + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload Scorecard artifact + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: OpenSSF-Scorecard + path: results.sarif + if-no-files-found: error + retention-days: 5 + + - name: Upload Scorecard results to code scanning + if: always() + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: results.sarif diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..a3097cb --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,68 @@ +name: security + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + - cron: "17 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + dependency-audit: + name: dependency-audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up .NET 8 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 8.0.x + + - name: Verify .NET 8 SDK selection + shell: bash + run: | + selected_version="$(dotnet --version)" + if [[ "${selected_version}" != 8.* ]]; then + echo "Expected a .NET 8 SDK, but dotnet selected ${selected_version}." + dotnet --info + exit 1 + fi + + - name: Audit direct and transitive NuGet dependencies + run: >- + dotnet restore SolSharp.sln + -p:NuGetAudit=true + -p:NuGetAuditMode=all + -p:NuGetAuditLevel=low + -warnaserror + + # Benchmarks are intentionally kept outside SolSharp.sln, so audit their graph explicitly. + - name: Audit benchmark dependencies + run: >- + dotnet restore benchmarks/SolSharp.Benchmarks/SolSharp.Benchmarks.csproj + -p:NuGetAudit=true + -p:NuGetAuditMode=all + -p:NuGetAuditLevel=low + -warnaserror + + dependency-review: + name: dependency-review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: low diff --git a/.gitignore b/.gitignore index db261ee..c0b5293 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ lcov.info packages/ # Secrets & local config +id.json appsettings.*.local.json secrets.json *.key diff --git a/CHANGELOG.md b/CHANGELOG.md index 4004387..cf9721a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,187 @@ All notable changes to SolSharp are documented here. The format is loosely based [semantic versioning](https://semver.org) — from 1.0.0 breaking changes only come with a major version (on the earlier 0.x releases, minor versions could carry them). +## [2.0.0] - 2026-08-09 + +### Added + +- Published `docs/RUST_PARITY.md` with immutable Anza Solana SDK, Agave, System, ALT, SPL Token, + Token-2022, and ATA source pins, explicit client/runtime boundaries, and release verification gates. + `THIRD_PARTY_NOTICES.md` records attribution and is included in the NuGet package. +- Added distinct `Hash` and `Signature` value types with exact byte/base58 semantics, typed blockhash and + signing overloads, strict signature verification, verified external `Presigner` support, and `NullSigner` + placeholders for multi-stage/offline signing. Transactions now expose ordered typed signature slots and + required keys, explicit `PartialSign` / `SignAll`, verified `AddSignature`, completeness and per-slot + verification, exact signable bytes, and verify-and-hash. `TransactionMessageHash` computes the Rust SDK's + domain-separated BLAKE3 message hash and is checked against its upstream known-answer vector. +- Added the pinned Solana SDK version-0 `OffchainMessage` contract: canonical ASCII/UTF-8 formats, + domain-separated serialization and SHA-256 hashing, strict bounded parsing, typed signing, verification, + and exact upstream vectors. `Keypair` can now deliberately export the Rust/wallet 64-byte form, its + 32-byte seed, base58, or `solana-keygen id.json`, with defensive byte copies and secret-cleanup guidance. +- Added the pinned minimal-public-key-size BLS12-381 proof-of-possession scheme used by Vote v2/v4: + validated compressed public keys/signatures/proofs, deterministic and signer-derived keys, vote-account + proof binding with pre-serialization validation, exact Rust-compatible 128-byte/zeroable UTF-8 JSON key + files, strict allocation-bounded base64, PoP-provenance-gated same-message key/signature aggregation, + upstream vectors, native-AOT coverage, + and typed Vote builder overloads. The packaged native backend supports Linux x64/arm64, macOS x64/arm64, + and Windows x64; subgroup, infinity, canonical-secret, and secret-cleanup checks are enforced locally. + Matching the Rust SDK's rogue-key boundary, raw public keys validate proofs but signature verification is + exposed only through derived keypairs or proof-of-possession-verified wrappers. +- Added the feature-gated SIMD-0385 V1 message and transaction format: inline execution configuration, + `0x81` version routing, message-first/fixed-signature framing, compile/sanitize/serialize/deserialize/decompile, + version-aware builders, and exact pinned Rust wire vectors. The client documents cluster activation and + zero-valued omitted compute/data limits rather than implying universal runtime availability. +- Added System `create_with_seed` address derivation, `SystemProgram.CreateAccountAllowPrefund`, and + `AssociatedTokenAccount.RecoverNested`, including upstream vectors and exact signer/writable layouts. + `SystemProgram.TransferMany` and `CreateNonceAccountWithSeed` mirror the remaining stable client helpers. +- Completed the classic SPL Token instruction family with the current mint/account/multisig initializers, + account-size and UI-amount queries, immutable-owner and sync-native variants, excess-lamport withdrawal, + unwrap, and batch instructions. +- Added typed Token-2022 construction for base extension allocation, transfer fees, default account state, + required transfer memos, CPI guard, interest-bearing mints, transfer/metadata/group pointers, scaled UI + amounts, pausing, checked and confidential permissioned burns, and the Token Metadata interface. +- Added pinned native-program clients for Stake and Vote (including compact/tower and V2/BLS layouts), + legacy/upgradeable/V4 loaders, Ed25519/Secp256k1/Secp256r1 verification precompiles, Address Lookup + Table state (including SlotHashes-aware activation, active-prefix, and lookup semantics), raw memo bytes, + and a bounded Instructions sysvar constructor/decoder for off-chain instruction introspection. Strict + account decoders cover stake, loader, ALT, and feature state. +- Added all current sysvar IDs and bounded decoders for Clock, Rent, EpochSchedule, EpochRewards, + LastRestartSlot, SlotHashes, SlotHistory, and StakeHistory, plus strict version-preserving Vote + V1.14.11/V3/V4 account-state decoders and Feature Gate activation/revocation clients. +- Added SPL token-group and member builders/state, transfer-hook validation PDA and extra-account-meta + codecs/resolution, confidential-transfer/fee/mint-burn instruction families, native proof-program POD + instructions, ElGamal registry state, and typed classic/Token-2022 instruction, base-account, metadata, + group, hook, and extension decoders. Cryptographic proof/ciphertext generation remains explicitly + caller-supplied rather than being replaced by an unverifiable local implementation. +- Added a forward-compatible local `global.json`; after `setup-dotnet` installs 8.x, every CI/release job + asserts the SDK resolver's actual selected version, so preinstalled newer SDKs cannot invalidate the + minimum-SDK gate. Release publishing now fails unless the pushed tag exactly matches the + package version, requires private live-cluster endpoints with zero skipped or inconclusive integration + paths, verifies the canonical devnet genesis hash before any write, and Native-AOT publishes and runs the + exact packed artifact from an isolated package cache before pushing it. Duplicate immutable NuGet versions + fail visibly instead of becoming a green no-op. +- Added centrally configured StyleCop analysis to every project. Rider, Roslyn, and StyleCop now share an + explicit modifier order, while repository-conflicting documentation and legacy-layout rules are suppressed + in `.editorconfig` instead of producing misleading IDE warnings. CI and release now require a clean + solution-wide `dotnet format --severity info` analyzer pass in addition to the warning-free build. +- Added merged unit-test coverage reporting: 93.7% line coverage across the four + hand-written production assemblies, with generated sources excluded, full branch details published, and + a 90% line gate. Scheduled direct/transitive NuGet auditing, pull-request dependency review, CodeQL + `security-extended`, Dependabot updates, OpenSSF Scorecard reporting, Node 24 action pins, and release-package + provenance attestations harden the GitHub supply chain without representing automated results as an + independent security audit. +- SPL Token and Token-2022 authority-bearing instruction builders now have additive multisig overloads: + the multisig authority remains a non-signer account and the supplied member accounts are appended as + readonly signers in caller order. +- `RpcException.ErrorData` preserves the optional JSON-RPC `error.data` payload (including preflight logs + and units consumed). `SolanaWsClientOptions.SubscriptionAckTimeout` bounds initial and replayed + subscription acknowledgements, while `MaxPendingSubscriptionRequests` caps live ACK waits plus compact + late-ACK cleanup records. +- Added the current Agave `getAgGenesisCert` read as `GetAgGenesisCertificateAsync`, including typed + Alpenglow block-certificate and aggregate-signature models. +- Added source- and binary-compatible explicit maximum-version reads and block subscriptions. Raw and parsed + HTTP/WebSocket paths can opt into V1, parsed messages preserve Agave's inline `transactionConfig`, and the + existing method names remain pinned to legacy/v0 for behavior compatibility. +- Completed the effective pinned HTTP/PubSub configuration surface through source-safe, explicitly named + options/filter methods: minimum-context-slot reads; the full legacy/base58/base64/jsonParsed/base64+zstd + account-data union; HTTP account/program data slices and context-wrapped scans; mint-or-program token filters; + the full program-account filter union (base58/base64/raw memcmp, unsigned 64-bit data size, and + `tokenAccountState`) with pinned validation limits; + sortable supply/leader/vote options; raw block/transaction encoding-detail-reward choices; exact logs/block + filter unions; parsed program subscriptions; and the optional early `receivedSignature` notification before + final processing. `SubscribeAccountWithOptionsAsync` and `SubscribeProgramWithOptionsAsync` expose only the + encoding/commitment/filter fields that pinned Agave actually applies and preserve the exact account-data + response union, including unknown-program `jsonParsed` fallback and `base64+zstd`. +- `SimulateTransactionOptions` can now request post-simulation account snapshots in the full effective + base64, base64+zstd, or jsonParsed account-data union and parsed inner + instructions. Simulation and transaction models preserve current node fields including transaction + version/index, cost and loaded-data units, fees, balances, return data, rewards, loaded addresses, + parsed v0 lookup references, RPC API version, validator endpoints/client id, and basis-point commissions. +- Added `SystemProgram.UpgradeNonceAccount`, matching the generated System Program client's discriminator + and account layout for migrating legacy nonce state. + +### Changed + +- Blockhash and durable-nonce APIs now accept the typed `Hash` value alongside their existing string forms. + Existing calls that pass an untyped `null` or `default` literal must cast it to `string` (or use a typed + `Hash`) to disambiguate overload resolution; ordinary string and `Hash` calls are unchanged. +- RPC models now use the pinned wire widths and closed unions instead of permissive signed/JSON containers: + `DataSlice` offsets and lengths are `ulong`; transaction versions use `RpcTransactionVersion`; vote epoch + credits and block-production counts are exact typed tuples; transaction indexes use `byte`/`uint`; and + simulation account snapshots use the lossless `RpcAccountInfo` union. This is a deliberate 2.0 source and + binary migration for callers that stored these fields in the former broad types; assemblies built against + 1.x must be recompiled for 2.0. + +### Fixed + +- Serialized secret-dependent Ed25519/BLS keypair operations against disposal, so concurrent disposal can + no longer zero a key while it is being signed or exported. Typed Vote BLS credentials now return defensive + copies and serialize through private validated bytes, preventing public memory views from mutating a + proof-of-possession-checked instruction after validation. Secret JSON-export temporaries are cleared on + allocation and serialization failures as well as successful completion. +- Bounded Token-2022 metadata vector counts before allocation, preventing a malformed four-byte Borsh + length from requesting a multi-gigabyte `List` capacity. +- Matched precompile runtime count semantics: Ed25519 and Secp256r1 offset tables use the first header + byte, Secp256r1 accepts only 1-8 signatures, and zero-count trailing-data cases are rejected instead of + producing or decoding instructions that Agave refuses. +- Kept the durable nonce account in v0 static keys even when it also appears in an Address Lookup Table, + matching the Solana SDK and preserving runtime durable-nonce recognition, including valid advance-nonce + instructions with trailing data. Durable-nonce-only legacy and v0 builders are now accepted. +- Matched current canonical program builders: Address Lookup Table creation no longer requires the future + authority to sign, Associated Token Account creation emits its explicit `[0]` discriminator, memo text + rejects invalid Unicode instead of encoding replacement characters, and undefined Token/Token-2022 + authority discriminators are rejected before serialization. +- Hardened Ed25519 verification against small-order public-key/signature points accepted by the underlying + crypto backend, closing a signature-malleability edge while retaining Solana-compatible mixed-torsion + behavior. Secret-key public-half validation is constant-time, and SLIP-0010 paths now reject signed or + whitespace-padded numeric segments. +- Preserved parameter payloads for all current transaction-error variants instead of dropping their + instruction/account indexes, and retained the current optional fields returned by account, transaction, + simulation, cluster-node, inflation, token, and parsed-message RPC responses. +- Validated account ownership, executable state, canonical fixed/TLV layouts, nonce versions, Address + Lookup Table option tags/padding/address alignment, and classic Token account sizes before typed decode. + Confirmation polling now falls back to the upstream `confirmations` semantics when an older node omits + `confirmationStatus`. +- Address Lookup Table reads now retain the RPC context slot, the full stored address list, and the + `last_extended_slot_start_index`; transaction-facing addresses exclude same-slot additions, while lifecycle + and nullable usability distinguish active, cooling-down, and status-unknown deactivation states without + incorrectly treating every requested deactivation as unusable. +- Added a configurable 128 MiB default limit for single and batch HTTP response bodies, enforced while + streaming even when `Content-Length` is absent, so a provider cannot cause an unbounded response buffer. +- Hardened batch JSON-RPC handling: every entry must be a valid 2.0 envelope with one known, unique id and + exactly one result or error member (including rejecting `result` together with `error: null`); malformed + replies now terminate every queued task instead of leaving calls pending indefinitely. +- Serialized concurrent WebSocket connects, disposed sockets from failed initial/reconnect attempts, + completed one-shot signature subscriptions after their notification, bounded subscribe acknowledgement + waits, isolated cancellation and acknowledgement failures during reconnect replay, and bounded abandoned-ACK + state so one failed subscription cannot stall the replay queue or leak retained subscription state. +- WebSocket routing now accepts the protocol's full unsigned 64-bit subscription-id range, returned channel + subscriptions support concurrent consumers, and signature confirmation accepts arbitrarily long or + infinite timeouts without overflowing the platform timer. Shared-socket sends isolate one subscriber's + cancellation, duplicate server IDs fail the corrupted generation, and disposal completes the close handshake. +- WebSocket notifications now validate JSON-RPC versions and the subscription family's exact method before + routing, reject null or incomplete context payloads without leaving readers pending, and isolate malformed + scalar payloads to their own subscription. An unsolicited `receivedSignature` event can no longer satisfy a + final-only confirmation, and a transport-originated cancellation no longer suppresses later reconnect attempts. +- Exact account responses now require every mandatory upstream field, context wrappers require context/value/slot + presence, and malformed single-call JSON-RPC error objects are rejected instead of fabricating code `0` and an + empty message. The final guaranteed ALT cooldown slot (`deactivation_slot + 512`) remains classified as usable. +- `ConfirmTransactionAsync` now applies its timeout to an in-flight HTTP status request as well as the delay + between polls. The DI resilience policy no longer retries non-idempotent `requestAirdrop` calls. +- Compiled messages defensively copy instruction data; transactions snapshot the bytes used for their first + signature, validate custom signer output is exactly 64 bytes, and serialize those same signed bytes even if + caller-owned message arrays are later mutated. +- Rejected unrepresentable signer counts before message header conversion, legacy messages whose signer-count + high bit collides with the version prefix, trailing bytes in full message and transaction parsers, and + System Program seeds longer than 32 UTF-8 bytes. Transaction builders now report null signer elements as + argument errors instead of failing indirectly while selecting the fee payer. +- Made Borsh decoding canonical (`bool`/`Option` accept only `0` or `1`) and UTF-8 strict in both directions; + malformed text is rejected instead of silently replaced. Core JSON converters now consistently throw + `JsonException` for wrong token kinds and the source-generated Core context supports nullable public keys. +- Tightened secret-buffer cleanup in BIP-39 and key parsing exception paths. +- CI now validates the packed public API against 1.3.0 and runs the Native AOT smoke test against the actual + generated NuGet package rather than direct project references. + ## [1.3.0] ### Added @@ -320,7 +501,9 @@ bundles four layered assemblies. transaction building, signing and serialization, `Transaction.Deserialize`, and instruction decompilation — every wire format validated byte-for-byte against the Rust `solana-sdk`. -[Unreleased]: https://github.com/jecacs/SolSharp/compare/v1.2.0...HEAD +[Unreleased]: https://github.com/jecacs/SolSharp/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/jecacs/SolSharp/compare/v1.3.0...v2.0.0 +[1.3.0]: https://github.com/jecacs/SolSharp/releases/tag/v1.3.0 [1.2.0]: https://github.com/jecacs/SolSharp/releases/tag/v1.2.0 [1.1.0]: https://github.com/jecacs/SolSharp/releases/tag/v1.1.0 [1.0.1]: https://github.com/jecacs/SolSharp/releases/tag/v1.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index 87d2dee..95b3b4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,16 +1,21 @@ # 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. +A modern, contract-driven .NET SDK for Solana: keys and signing, program instructions, +transaction wire formats, RPC, and WebSocket streaming. It is optimized for low latency, +bounded hostile-input handling, focused dependencies with a dependency-light Core, and Native AOT. -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. +Status: 2.0.0. SolSharp is independently implemented +against immutable Anza Solana SDK, Agave, and SPL source revisions; exact pins, client-side +coverage, verification criteria, and deliberate node/runtime exclusions live in +`docs/RUST_PARITY.md`. All JSON used by the library is source-generated and all four functional +assemblies are Native AOT compatible; the package also contains a minimal facade. The live integration suite exercises read, streaming, +and devnet write paths against real nodes. ## 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 build` — Roslyn and StyleCop code style is enforced on build (`EnforceCodeStyleInBuild`), so actionable style violations surface as warnings. Repository-specific StyleCop suppressions and the shared Rider/Roslyn modifier order live in `.editorconfig`. - `dotnet test` — NUnit suite. - `dotnet format` — auto-applies the style. Note: it cannot auto-fix naming (IDE1006); fix those by hand. @@ -21,16 +26,21 @@ Run from the repo root (where `SolSharp.sln` lives): - **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 ``, ``, and thrown ``); 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. +- **Target framework is `net8.0`.** Do not use net9-only APIs (e.g. `JsonStringEnumMemberName`, + `InlineArray`-based span tricks that need newer ref-safety). `global.json` starts at SDK 8.0.100 + with `rollForward: major`, so a development machine with only a newer SDK can still build the + repository. Hosted runners also contain newer SDKs; after installing 8.x, every CI/release job asserts + the resolver's actual `dotnet --version` is 8.x. Do not remove that check or CI could silently stop + proving the minimum if SDK selection or runner contents change. +- **Modern C# 12 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 (CS1591 enforces presence on production members; full ``, ``, and `` content remains a review policy); (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. +- **Wallet** — Ed25519 and BLS12-381 key/signature engines plus offline signing contracts. 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). @@ -38,20 +48,22 @@ 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). +- **Cryptographic key/signature engines belong in `Wallet`, never in `Core`.** Ed25519 verification is exposed as an extension on `PublicKey` from Wallet; the BLS value types and native backend also stay there. ## Layout ``` SolSharp/ - src/SolSharp.Core/ Encoding/ Primitives/ Converters/ Constants/ + src/SolSharp.Core/ Encoding/ Primitives/ Converters/ Constants/ SysvarStates/ 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.Wallet/ Ed25519/BLS keys, signers, verification, off-chain messages + src/SolSharp.Programs/ native/SPL clients and states, legacy/v0/V1 messages and transactions, 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 + samples/ SolSharp.AotSmoke: the Native AOT smoke sample, part of the solution (so regular builds compile it); CI packs SolSharp, consumes that nupkg, publishes with PublishAot, and runs the binary + docs/ USAGE.md task guide and RUST_PARITY.md pinned compatibility matrix + THIRD_PARTY_NOTICES.md compatibility-source pins and native dependency attribution ``` ## Testing @@ -70,18 +82,22 @@ SolSharp/ - 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. +- Never expose or export a raw private key to an RPC provider, hosted service, or third-party transaction builder. Keep signing behind `ISigner`; cryptographic backends are vetted implementation dependencies, not key-custody integrations. 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`/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()` 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`). +- **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 the 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 converter-attributed Core wire converters such as `CommitmentJsonConverter`, `PublicKeyJsonConverter`, and `HashJsonConverter` are **public**: keep these converters 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`/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()` needs explicit registration. Every production project sets `IsAotCompatible` — the trim/AOT analyzers plus `-warnaserror` reject `RequiresUnreferencedCode`/`RequiresDynamicCode` APIs (e.g. `JsonSerializer.Serialize(..., options)` overloads, `ValidateDataAnnotations`). CI consumes the generated nupkg for its native smoke test, so the packaging layer is covered too. - **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. +- **BLS12-381 lives in `Wallet` on `Nethermind.Crypto.Bls` 1.0.5 / Supranational `blst`** — the pinned Solana min-pk POP ciphersuite is implemented over a vetted native backend, never hand-rolled. Parse and verify paths require canonical subgroup points and reject infinity; secrets are canonical little-endian scalars and are zeroed. Supported packaged RIDs are Linux x64/arm64, macOS x64/arm64, and Windows x64; keep the facade dependency and `THIRD_PARTY_NOTICES.md` in sync. - `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). +- Solana version-0 signed off-chain messages live in `Wallet`: they use the pinned SDK's exact + `0xffsolana offchain` domain, canonical bounded ASCII/UTF-8 formats, SHA-256 hashing, and the same strict + Ed25519 signer/verification path. They are not transactions and examples must not imply on-chain authority. +- Transactions support **legacy**, **v0**, and feature-gated **SIMD-0385 V1** messages behind `ITransactionMessage`. Account ordering matches the pinned Solana SDK (fee payer first, then public-key byte order within writable-signer / readonly-signer / writable / readonly classes). v0 drains eligible accounts into lookup tables and prefixes `0x80`; V1 prefixes `0x81`, keeps all addresses inline, carries an inline execution config, and writes the message before its fixed number of signatures. V1's omitted compute/data limits mean zero and cluster activation is external, so examples must set deliberate limits and never imply universal availability. All formats use exact pinned Rust vectors. - `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. +- **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. Package validation compares the packed public API with the previous stable `PackageValidationBaselineVersion`; update that baseline deliberately when preparing each release. diff --git a/Directory.Build.props b/Directory.Build.props index 0050667..aa706c7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - latest + 12.0 enable enable @@ -18,14 +18,14 @@ - 1.3.0 + 2.0.0 Yevhen Koval SolSharp SolSharp — Solana SDK for .NET Copyright (c) 2026 Yevhen Koval MIT README.nuget.md - solana;solana-sdk;web3;blockchain;rpc;websocket;wallet;spl-token;token-2022;transactions;ed25519;crypto;defi;nativeaot;aot;trimming;source-generators + solana;solana-sdk;web3;blockchain;rpc;websocket;wallet;spl-token;token-2022;transactions;ed25519;bls12-381;crypto;defi;nativeaot;aot;trimming;source-generators git https://github.com/jecacs/SolSharp https://github.com/jecacs/SolSharp @@ -45,6 +45,7 @@ + @@ -52,4 +53,11 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/README.md b/README.md index dbabf38..fd2de38 100644 --- a/README.md +++ b/README.md @@ -7,70 +7,109 @@ [![NuGet](https://img.shields.io/nuget/v/SolSharp.svg?logo=nuget)](https://www.nuget.org/packages/SolSharp) [![Downloads](https://img.shields.io/nuget/dt/SolSharp.svg?logo=nuget)](https://www.nuget.org/packages/SolSharp) [![build](https://github.com/jecacs/SolSharp/actions/workflows/ci.yml/badge.svg)](https://github.com/jecacs/SolSharp/actions/workflows/ci.yml) +[![Security checks](https://github.com/jecacs/SolSharp/actions/workflows/security.yml/badge.svg?branch=main)](https://github.com/jecacs/SolSharp/actions/workflows/security.yml?query=branch%3Amain) +[![CodeQL](https://github.com/jecacs/SolSharp/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/jecacs/SolSharp/actions/workflows/codeql.yml?query=branch%3Amain) +[![Unit test coverage](https://img.shields.io/badge/unit_test_coverage-93.7%25_line-brightgreen)](#quality-gates) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/jecacs/SolSharp/badge)](https://scorecard.dev/viewer/?uri=github.com/jecacs/SolSharp) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -A lean, modern, Native AOT-ready .NET SDK for Solana — RPC, WebSocket streaming, and -wire-level transaction signing and building. No reflection anywhere: all JSON is -source-generated, and every assembly compiles clean to a native binary. - -SolSharp is built for low latency and a small dependency footprint. It is a focused, -hackable alternative to the heavier general-purpose SDKs: you get direct control over the -wire format and the signing path, without dragging in a large dependency graph. If you are -writing bots, indexers, or backend services that talk to Solana from .NET and care about -speed and control, this is aimed at you. - -> **Status: 1.2.0 — stable release.** SolSharp ships as a single NuGet package — `SolSharp` — -> bundling the Core (primitives + encodings), Wallet (Ed25519 keys, signing, verification, BIP-39/SLIP-0010 -> mnemonic import), Rpc (the full JSON-RPC HTTP read surface + send/simulate + WebSocket streaming + DI), and +A modern, contract-driven, Native AOT-ready .NET SDK for Solana — keys and signatures, +program instructions, transaction wire formats, RPC, and WebSocket streaming. SolSharp is +independently implemented in C# from pinned Anza Solana SDK, Agave, and SPL source contracts; +money-critical encodings are checked against exact upstream-compatible vectors. No reflection +is used by the library: JSON is source-generated, all four functional assemblies declare Native AOT +compatibility, and CI native-publishes and runs a consumer of the packed NuGet artifact. + +SolSharp is built for low latency with focused dependencies and a dependency-light Core. It exposes the protocol +instead of hiding it: typed byte-level values, explicit signing, exact instruction layouts, +bounded codecs, and typed network responses. If you are +writing wallets, bots, indexers, or backend services that talk to Solana from .NET and care +about correctness, speed, and control, this is aimed at you. + +> **Status: 2.0.0.** SolSharp ships as a single NuGet package — `SolSharp` — +> bundling the Core (primitives + encodings), Wallet (Ed25519 and BLS12-381 keys, signing, verification, +> key import/export, BIP-39/SLIP-0010 derivation, and signed off-chain messages), Rpc (the full applicable +> non-admin JSON-RPC HTTP surface + WebSocket streaming + DI), and > Programs (instructions + transaction building + signing, durable nonces) assemblies. JSON is -> source-generated and all four assemblies are Native AOT compatible. Versioning follows semver: from 1.0.0, +> source-generated and all four functional assemblies are Native AOT compatible. Versioning follows semver: from 1.0.0, > breaking changes only come with a major version. 📖 **New here? Read the [usage guide](docs/USAGE.md)** — a task-oriented cookbook covering keys, reads, -SPL token state, building/signing/sending transactions, v0 + address lookup tables, decoding transactions, +SPL token state, building/signing/sending transactions, v0 + address lookup tables, SIMD-0385 V1, decoding transactions, WebSocket subscriptions, confirmation, Native AOT publishing, and more. ## Motivation When this was started, the .NET options for Solana were either unmaintained and stale or heavy and not built for performance — there was no modern, fast, actively-developed client. -SolSharp is a from-scratch answer to that: current C#, allocation-conscious, and tuned for -latency-sensitive workloads. +SolSharp is an independently written C# 12 answer: allocation-conscious, tuned for +latency-sensitive workloads, and engineered from the Rust implementations that define the +network's actual wire behavior rather than from prose documentation alone. + +## Upstream provenance and parity + +Compatibility work is tied to immutable source revisions. The +[Rust parity matrix](docs/RUST_PARITY.md) records the exact Anza Solana SDK, Agave, System, +Address Lookup Table, SPL Token, Token-2022, and Associated Token Account commits used for +each contract, what SolSharp currently covers, and what is intentionally outside a client +SDK. Tests use upstream known-answer vectors or independently generated compatible vectors; +a C# round trip alone is not treated as proof of wire compatibility. + +SolSharp is not a generated binding, a fork, or an official Anza/Solana product. It is an +idiomatic C# implementation whose public client behavior is verified against the pinned Rust +sources. See [third-party notices](THIRD_PARTY_NOTICES.md) for attribution and licensing. ## Why - **Native AOT ready.** JSON is source-generated (no reflection anywhere), every assembly is trimmable - and AOT-clean, and CI publishes and runs a native-compiled smoke sample on every push. Ship your bot + and AOT-clean, and CI publishes and runs a native-compiled smoke sample on every push and pull request + targeting `main`. Ship your bot as a self-contained native binary with instant startup. - **Lean.** No kitchen-sink dependency graph. `Core` depends on a single package (base58). -- **Wire-level control.** Hand-rolled, spec-accurate transaction and message encoding — the part - most SDKs hide — with Ed25519 signing on a vetted crypto library, all tested against known vectors. +- **Wire-level control.** Hand-written, bounds-checked transaction, message, account, and instruction + codecs — the part most SDKs hide — with Ed25519 signing on a vetted crypto library and exact + vectors derived from the pinned Rust contracts. - **Latency-minded.** Value types, allocation-free hot paths, span-based APIs. -- **Modern .NET.** C# latest, nullable reference types, code style enforced on build. +- **Modern .NET 8.** C# 12, nullable reference types, code style enforced on build. ## How it compares to Solnet -[Solnet](https://github.com/bmresearch/Solnet) is the longest-standing .NET SDK for Solana — actively -maintained, with a wide ecosystem of program integrations. SolSharp is not a fork of it: it is a -from-scratch SDK with different priorities. The differences that actually matter when choosing: - -| | SolSharp | Solnet | -| --- | --- | --- | -| **Native AOT & trimming** | Guaranteed and CI-enforced: source-generated JSON (zero reflection), `IsAotCompatible`, trim/AOT analyzers as errors, a native-compiled smoke test on every push | No AOT/trimming compatibility markings | -| **API shape** | `async`-only; methods return typed values and throw typed exceptions (`RpcException`) | Sync + async; calls return `RequestResult` wrappers to unwrap | -| **Packaging** | One package, four layered assemblies | Per-area packages (`Solana.Rpc`, `Solana.Wallet`, `Solana.Programs`, ...) | -| **Program coverage** | Focused core: System, Token + Token-2022 extensions, ATA, Compute Budget, Memo, Address Lookup Table | Broader: also Stake, Governance, StakePool, Name Service, and ecosystem packages (Metaplex, Raydium, Jupiter, ...) | -| **Footprint** | Hot paths allocation-free; RPC depends on `Logging.Abstractions` only | RPC package pulls full `Logging` + `Logging.Console` | - -Pick **Solnet** when you need its program breadth — NFT/DEX integrations and the wider ecosystem -packages around it. Pick **SolSharp** when you care about native binaries and startup time, typed -async APIs, minimal dependencies, and wire-level control with byte-for-byte verified encoding — -bots, indexers, latency-sensitive backends. +[Solnet](https://github.com/bmresearch/Solnet) is the longest-standing .NET SDK for Solana and has a +valuable ecosystem-oriented program surface. SolSharp is independently written with a different target: +application-side parity with immutable official Rust contracts, plus a verifiable .NET deployment story. +The official Rust column below is the reference contract rather than another client implementation. + +Comparison basis: SolSharp is release `2.0.0`; Solnet means its +[published `8.7.0` release](https://github.com/bmresearch/Solnet/commit/e8df87bdb2006376ba3eea9e1d3b857c84fc5685) +(2025-11-26), with unreleased-head differences called out explicitly; the Rust reference is the +[pinned Anza SDK/Agave/SPL matrix](docs/RUST_PARITY.md). + +| Dimension | Official Rust SDK / Agave reference | SolSharp 2.0.0 | Solnet official packages/source | +| --- | --- | --- | --- | +| **Transaction formats** | Legacy, V0, and feature-gated [SIMD-0385 V1](https://github.com/anza-xyz/solana-sdk/blob/ec7a0467e268774b724d55120ad952b518f27d64/message/src/versions/v1/message.rs), including inline V1 configuration and a message-first signature envelope | Legacy/V0/V1 build, sanitize, parse, sign, serialize, and decompile; exact V1 config/framing and envelope vectors | Published 8.7: Legacy/V0 and [rejects versions above 0](https://github.com/bmresearch/Solnet/blob/e8df87bdb2006376ba3eea9e1d3b857c84fc5685/src/Solnet.Rpc/Models/Message.cs#L275-L286). Unreleased head names V1, but its current body/envelope is not the pinned SIMD-0385 layout (details below) | +| **Native and SPL clients** | Canonical native-program and SPL interface crates, split by contract | System, Stake, Vote, legacy/upgradeable/V4 loaders, Compute Budget, ALT, Memo, three signature precompiles; Token, Token-2022 extensions/interfaces, ATA, metadata/group/transfer-hook, and ElGamal proof/registry client contracts with typed decoders | Broader ecosystem-oriented set including Governance, Stake Pool, Token Swap, Account Compression, Name Service, and Shared Memory; repository head adds an initial Token-2022 surface | +| **HTTP RPC** | [53 applicable non-admin, non-obsolete request variants](https://github.com/anza-xyz/agave/blob/ab6553293094e59dee7d3e7c928c7fa1023d0684/rpc-client-types/src/request.rs#L12-L75) in the pinned Agave client enum | 53/53 typed async methods, including current context-slot, filter, slice, encoding/detail/reward, raw/parsed V1, and context-wrapped response variants; batching, bounded responses, typed errors, and send/simulate/confirm | 50/53 pinned methods through sync/async `RequestResult` APIs; no `getAgGenesisCert`, `getRecentPrioritizationFees`, or `getStakeMinimumDelegation` at the examined head | +| **PubSub** | Nine families: account, program, logs, signature, slot, slots-updates, block, vote, and root | 9/9, including exact logs/block filter unions, parsed account/program forms, early signature-receipt events, bounded channels, cancellation isolation, reconnect/replay, and V1 block opt-ins | [Six families](https://github.com/bmresearch/Solnet/blob/ebec9e1a3b708dbe86d103dd8fcf869d0cd923b6/src/Solnet.Rpc/IStreamingRpcClient.cs): account, program, logs, signature, slot, and root | +| **Offline / multisig signing** | Signer, presigner/null-signer, partial signing, fixed signature slots, and per-slot verification primitives | Exact message-byte export/hash, typed fixed slots, partial/all signing, verified external insertion, `Presigner`, `NullSigner`, and SPL multisig builders | Partial signing, externally supplied signatures, and program multisig builders/examples | +| **AOT / trimming** | Native Rust output; not a .NET compatibility contract | Every assembly declares `IsAotCompatible`; generated JSON metadata, trim/AOT analyzers, and CI that publishes and runs a native package consumer | Targets .NET 8, but the examined projects publish no solution-wide AOT/trimming declaration or native-publish CI contract; reflection paths remain | +| **Packaging** | Modular Cargo crates | One NuGet package containing four compiler-layered functional assemblies plus a minimal packaging facade | Five installable packages: `Solana.Rpc`, `Solana.Wallet`, `Solana.Programs`, `Solana.Extensions`, and `Solana.KeyStore` | +| **Reproducibility** | The authoritative source itself | Seven immutable upstream revisions plus exact byte/KAT tests tied to named Rust/RFC/BIP/SLIP vectors | Own unit/RPC fixtures; published documentation has no equivalent immutable upstream revision matrix | + +Solnet's unreleased [`ebec9e1` head](https://github.com/bmresearch/Solnet/commit/ebec9e1a3b708dbe86d103dd8fcf869d0cd923b6) +contains `MessageV1`, but the examined [message](https://github.com/bmresearch/Solnet/blob/ebec9e1a3b708dbe86d103dd8fcf869d0cd923b6/src/Solnet.Rpc/Models/Message.cs#L256-L524) +and [transaction](https://github.com/bmresearch/Solnet/blob/ebec9e1a3b708dbe86d103dd8fcf869d0cd923b6/src/Solnet.Rpc/Models/Transaction.cs#L265-L290) +still use a V0-shaped body and signatures-first envelope. That is why the table does not count it as parity +with the pinned Rust [V1 envelope](https://github.com/anza-xyz/solana-sdk/blob/ec7a0467e268774b724d55120ad952b518f27d64/transaction/src/versioned/mod.rs#L345-L390). + +Choose **Solnet** when its broader ecosystem integrations or modular package topology match the application. +Choose **SolSharp** when exact pinned native/SPL wire contracts, complete pinned RPC/PubSub coverage, +Native AOT, bounded hostile-input behavior, and reproducible upstream parity are the primary constraints. ## Package SolSharp ships as a **single NuGet package** — `SolSharp` — so one `dotnet add package SolSharp` pulls in -everything. Internally it stays four layered assemblies, bundled into that one package (namespaces are +everything. Internally it stays four layered functional assemblies plus a minimal packaging facade, +bundled into that one package (namespaces are unchanged: `SolSharp.Core.*`, `SolSharp.Rpc`, `SolSharp.Wallet`, `SolSharp.Programs`): Install from [NuGet](https://www.nuget.org/packages/SolSharp): @@ -80,15 +119,15 @@ dotnet add package SolSharp ``` ```xml - + ``` | Assembly | Purpose | | ------------------ | ---------------------------------------------------- | | `SolSharp.Core` | Primitives, encoding, JSON, program/sysvar constants | -| `SolSharp.Wallet` | Ed25519 keys, key parsing, signing and verification | -| `SolSharp.Rpc` | Full HTTP JSON-RPC read surface + bounded, auto-reconnecting WebSocket streaming + DI | -| `SolSharp.Programs`| Instructions (System/Token/ATA/Memo/Compute Budget/ALT) + transaction building | +| `SolSharp.Wallet` | Ed25519/BLS keys, secure import/export, signing, verification, and off-chain messages | +| `SolSharp.Rpc` | Full applicable non-admin HTTP JSON-RPC surface + bounded, auto-reconnecting WebSocket streaming + DI | +| `SolSharp.Programs`| Native/SPL instructions and state decoders + legacy/v0/V1 transaction building | Keeping the split in the source means the layering stays compiler-enforced — dependencies point downward only: `Rpc` and `Wallet` build on `Core`, and `Programs` builds on `Core` and `Wallet`. `Core` depends on @@ -100,68 +139,92 @@ See the [changelog](CHANGELOG.md) for what changed in each release. `SolSharp.Core`: -- `PublicKey` — a 32-byte value type with value equality, base58 parsing, and JSON support. +- `PublicKey` and `Hash` — distinct 32-byte value types with value equality, base58 parsing, byte-copy APIs, + and source-generated JSON support. - `Base58`, `ShortVec` (compact-u16), and `BorshReader` / `BorshWriter` — the encodings Solana uses on the wire, plus a bounds-checked reader and writer for Anchor / Borsh account data and instruction arguments. - `Commitment` — an RPC enum that serializes to its exact wire string. -- `SolanaProgramIds`, `Sysvars`, `Mints` — well-known on-chain addresses, guarded by a test - that every constant decodes to a valid 32-byte key. +- `SolanaProgramIds`, `Sysvars`, `SolanaFeatureIds`, and `Mints` — well-known + on-chain addresses, guarded by tests that every constant decodes to a valid 32-byte key. +- Bounded current sysvar states for Clock, Rent, EpochSchedule, EpochRewards, LastRestartSlot, + SlotHashes, SlotHistory, and StakeHistory. - `SolanaUnits` — SOL ↔ lamports conversion. ```csharp using SolSharp.Core.Primitives; var mint = PublicKey.Parse("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"); -byte[] raw = mint.ToBytes(); // 32 bytes, allocation-free storage +byte[] raw = mint.ToBytes(); // new 32-byte copy bool ok = PublicKey.TryParse(input, out var key); ``` `SolSharp.Rpc`: -- HTTP JSON-RPC reads — the full current read surface: accounts (`getAccountInfo`, - `getMultipleAccounts`, `getProgramAccounts` with memcmp / data-size filters and data slices, +- HTTP JSON-RPC methods — the full applicable non-admin surface from the pinned Agave revision: accounts (`getAccountInfo`, + `getMultipleAccounts`, `getProgramAccounts` with the complete base58/base64/raw memcmp, unsigned data-size, + token-account-state filter union and data slices, `getTokenAccountsByOwner`, `getTokenAccountsByDelegate`, `getTokenLargestAccounts`, - `getTokenAccountBalance`, `getLargestAccounts`, `getAddressLookupTable` fetch + decode), + `getTokenAccountBalance`, `getLargestAccounts`, plus the `GetAddressLookupTableAsync` helper + (fetch + decode through `getAccountInfo`)), transactions and blocks (`getTransaction`, `getSignaturesForAddress`, `getSignatureStatuses`, `getBlock`, `getBlockHeight`, `getBlockTime`, `getBlockCommitment`, `getBlockProduction`, `getTransactionCount`, `getFeeForMessage`), and cluster state (`getBalance`, `getSlot`, `getLatestBlockhash`, `isBlockhashValid`, `getEpochInfo`, `getEpochSchedule`, `getVersion`, - `getHealth`, `getIdentity`, `getGenesisHash`, `getSupply`, `getSlotLeader`, `getSlotLeaders`, + `getHealth`, `getIdentity`, `getGenesisHash`, `getAgGenesisCert`, `getSupply`, `getSlotLeader`, + `getSlotLeaders`, `getRecentPrioritizationFees`, `getRecentPerformanceSamples`, `getTokenSupply`, `getMinimumBalanceForRentExemption`, `getVoteAccounts`, `getInflationReward`, `getInflationGovernor`, `getInflationRate`, `getLeaderSchedule`, `getBlocks`, `getBlocksWithLimit`, `getFirstAvailableBlock`, `getClusterNodes`, `getHighestSnapshotSlot`, `getMaxRetransmitSlot`, `getMaxShredInsertSlot`, `getStakeMinimumDelegation`, `minimumLedgerSlot`, `requestAirdrop`); each typed, fully documented, and tested. + Full configuration methods preserve upstream `minContextSlot`, context-wrapped account scans, + mint/program-id token filters, data slices, sorting, airdrop blockhashes, and the closed `RpcAccountData` + union: legacy binary, tagged base58/base64, parsed JSON with its unknown-program base64 fallback, and + `base64+zstd`. Raw transaction/block encoding, detail, and rewards choices remain explicit without + weakening the convenient defaults. - Account-state decoders — `Mint` and `TokenAccount` (SPL Token state, via `GetMintAsync` / `GetTokenAccountAsync`), `NonceAccount` (via `GetNonceAccountAsync`), `AddressLookupTable`, and the Token-2022 extension section (`TokenExtensionSet` — TLV walking plus typed views for transfer fees, metadata pointer / in-mint metadata, permanent delegate, and more); for other programs, pair `getAccountInfo` with Core's `BorshReader`. - `getTransaction` returns the decoded transaction bytes (feed to `Transaction.Deserialize`) alongside rich - metadata — pre/post SOL and token balances, inner (CPI) instructions, loaded lookup-table addresses, logs, - and compute units. Failures decode to a typed `TransactionError` (exposing the program's `Custom` code) on - `TransactionMeta`, `SignatureStatus`, and `SimulateTransactionResult`. + metadata — transaction version/index, pre/post SOL and token balances, inner (CPI) instructions, loaded + lookup-table addresses, logs, compute/cost units, program return data, and rewards. Failures decode to a + typed `TransactionError` (including parameterized runtime errors and the program's `Custom` code) on + `TransactionMeta`, `SignatureStatus`, and `SimulateTransactionResult`. The compatibility-preserving default + read advertises legacy/v0; `GetTransactionWithMaxVersionAsync(..., 1)` opts into V1 bytes, which + `Transaction.Deserialize` understands locally. - `GetParsedTransactionAsync` / `GetParsedBlockAsync` / `GetParsedAccountInfoAsync` return the node's `jsonParsed` decoding — typed instructions, token balances, account state, and logs without local Borsh - work — each instruction keeping both its parsed form and its raw program id / accounts / data. + work. Recognized instructions carry the node's parsed action; unrecognized instructions retain their raw + program id, account list, and base58 data, matching the upstream tagged response union. Explicit + `*WithMaxVersionAsync` variants opt parsed transaction/block reads into V1 and preserve its inline + `transactionConfig`. - WebSocket streaming multiplexed over one connection: `SubscribeSlotsAsync`, `SubscribeRootsAsync`, `SubscribeSlotsUpdatesAsync` (slot lifecycle with per-stage stats), and `SubscribeVotesAsync` (gossip votes) as `IAsyncEnumerable`; `SubscribeLogsAsync`, `SubscribeAccountAsync`, `SubscribeParsedAccountAsync`, `SubscribeProgramAsync`, `SubscribeSignatureAsync`, `SubscribeBlocksAsync`, and `SubscribeParsedBlocksAsync` (`ChannelReader`) — with automatic reconnect and resubscribe across dropped connections, and a bounded - transport (message-size cap, per-subscription buffers, opt-in receive timeout). + transport (message-size cap, per-subscription buffers, opt-in receive timeout). The source-safe + `SubscribeAccountWithOptionsAsync` and `SubscribeProgramWithOptionsAsync` paths expose the effective + encoding/commitment fields (plus program filters) and return the same exact `RpcAccountData` union as HTTP. + Agave-accepted subscription fields that its encoder ignores are deliberately not advertised. Block + subscriptions also provide explicit `*WithMaxVersionAsync` V1 opt-ins; full methods cover logs/block filter + unions, parsed program streams, and the optional early `receivedSignature` event before final processing. - DI registration with a built-in resilience pipeline (retry on transient errors and HTTP 429), plus `AddSolanaWs` for a container-managed streaming client. - JSON-RPC batching — `CreateBatch()` queues reads (and sends) and submits them in one HTTP round-trip. -- `SendTransactionAsync` / `SimulateTransactionAsync` — submit a signed transaction or dry-run it for logs and - compute units, both running preflight/simulation at `confirmed` by default to match `GetLatestBlockhashAsync`; +- `SendTransactionAsync` / `SimulateTransactionAsync` — submit a signed transaction or dry-run it for logs, + compute units, account snapshots, parsed inner instructions, balances, fees, and return data; both run + preflight/simulation at `confirmed` by default to match `GetLatestBlockhashAsync`; `SendAndConfirmTransactionAsync` sends and waits for confirmation (throwing if the transaction lands but errors). Confirm by polling (`GetSignatureStatusesAsync` / `ConfirmTransactionAsync`) or over the WebSocket (`SolanaWsClient.ConfirmSignatureAsync`). ```csharp using SolSharp.Rpc; +using SolSharp.Rpc.Streaming; // typed client with retries; tune the pipeline via the optional callback services.AddSolanaRpc("https://your-rpc-endpoint"); @@ -179,12 +242,24 @@ await foreach (var slot in ws.SubscribeSlotsAsync()) `SolSharp.Wallet`: - `Keypair` — generate a key, or load one with `Parse` (auto-detecting a base58 export, a `solana-keygen` - JSON array, hex, or base64); signs messages and zeroes its secret on dispose (or finalization). + JSON array, hex, or base64); export the Rust/wallet 64-byte, base58, or `id.json` forms deliberately; + signs messages and zeroes its stored seed on dispose (or finalization). +- `Signature` — a typed 64-byte base58 value with strict verification; `Presigner` validates externally + produced signatures against the requested message, while `NullSigner` represents an absent offline cosigner. +- `BlsKeypair`, `BlsPublicKey`, `BlsSignature`, and `BlsProofOfPossession` — the pinned minimal-public-key-size + BLS12-381 proof-of-possession scheme used by current Vote v2/v4 contracts, with subgroup/infinity validation, + Rust-compatible binary/zeroable UTF-8 JSON key files, strict fixed-size base64 text, and vote-account-bound + proofs that typed Vote builders verify locally before serialization. Signature verification is available only + through a derived keypair or `BlsPopVerifiedPublicKey`; `BlsAggregatePublicKey` likewise admits only PoP-verified + keys for safe same-message aggregation. +- `OffchainMessage` — the pinned SDK's version-0, domain-separated signed-message format with canonical + ASCII/UTF-8 selection, strict parsing, typed hashes, signing, and verification. - Mnemonic import — `FromMnemonic` (the `solana-keygen` scheme) and `FromMnemonicAtPath` (the Phantom / Solflare SLIP-0010 scheme), built on the public `Bip39` and `Slip10` helpers and validated against the official test vectors. - `ISigner` — the signing abstraction the transaction builder depends on, so the key stays swappable. -- `PublicKey.Verify(message, signature)` — Ed25519 verification, kept in Wallet so Core stays crypto-free. +- `PublicKey.Verify(message, signature)` — Solana-compatible strict Ed25519 verification (including rejection + of small-order public keys and signature points), kept in Wallet so Core stays crypto-free. ```csharp using SolSharp.Wallet; @@ -197,23 +272,33 @@ bool ok = keypair.PublicKey.Verify(message, signature); `SolSharp.Programs`: -- Instruction builders: `SystemProgram` (transfer, create / allocate / assign — plus `CreateAccountWithSeed`, - `AllocateWithSeed`, `AssignWithSeed`, `TransferWithSeed` — and the durable-nonce set, including the - `CreateNonceAccount` pair), `ComputeBudgetProgram` (compute-unit limit, priority fee, `RequestHeapFrame`, - `SetLoadedAccountsDataSizeLimit`), `TokenProgram` (transfer, mint, burn, approve — checked variants - included — revoke, `SetAuthority` via `AuthorityType` (incl. the Token-2022 extension authorities), - freeze / thaw, initialize mint / account, close, sync-native — each with a `tokenProgram` override for - Token-2022), `AssociatedTokenAccount` (create and `CreateIdempotent`), `AddressLookupTableProgram` - (create / extend / freeze / deactivate / close), and `MemoProgram`. -- `ProgramDerivedAddress` (`FindProgramAddress` / `TryCreateProgramAddress`) and `PublicKey.IsOnCurve()`. -- `Message` (legacy) and `MessageV0` (versioned, loading extra accounts from address lookup tables), - `Transaction`, and `TransactionBuilder` (`Build` / `BuildV0`, `BuildMessage` / `BuildMessageV0` for the +- Native instruction clients: the current System and durable-nonce helpers; Stake and Vote (including + compact/tower and V2/BLS forms); legacy, upgradeable, and V4 loaders; Compute Budget; Address Lookup + Tables; Feature Gate; Memo; and self-contained/cross-instruction Ed25519, Secp256k1, and Secp256r1 verification. +- SPL clients: the pinned classic Token family (checked, multisig, batch, and newer interface helpers), + ATA create/idempotent/recover-nested, and Token-2022 base plus transfer-fee, default-state, memo/CPI, + pointer, interest/scaled, pausable, permissioned-burn, metadata, token-group, transfer-hook, + confidential-transfer/fee/mint-burn, native proof-program, and ElGamal registry contracts. +- Typed, bounds-checked decoders cover native/SPL account state and Token/Token-2022 instruction + discriminators, including nonce, stake, versioned vote, loader, ALT, Instructions sysvar, Token-2022 TLV, metadata, + token-group, transfer-hook metadata, and ElGamal registry data. Confidential builders consume exact + caller-generated POD/ciphertext/proof bytes; + SolSharp does not pretend to generate or verify zero-knowledge proofs off chain. +- `ProgramDerivedAddress` (`FindProgramAddress` / `TryCreateProgramAddress` / System `CreateWithSeed`) and + `PublicKey.IsOnCurve()`. +- `Message` (legacy), `MessageV0` (loading extra accounts from address lookup tables), and SIMD-0385 + `MessageV1` (inline execution configuration and fixed-width instruction framing), plus `Transaction` and + `TransactionBuilder` (`Build` / `BuildV0` / `BuildV1`, and matching `BuildMessage*` methods for the unsigned message, durable-nonce anchoring via `SetDurableNonce`) — compilation, wire serialization (allocation-free via `Transaction.TrySerialize` and the span `Serialize` overloads, with `Transaction.Deserialize` to parse one back — enforcing Solana's sanitize rules on malformed input — and `DecompileInstructions` to resolve a parsed message's - instructions to program ids and account keys, loading v0 lookup-table accounts), signing, and base64 - output. Every encoding is checked byte-for-byte against the Rust `solana-sdk` (via solders) and `solana-py`. + instructions to program ids and account keys, loading v0 lookup-table accounts), version-aware signing, and base64 + output. Offline coordination is explicit through typed `Signatures` / `RequiredSignerKeys`, `PartialSign`, + verified `AddSignature`, `SignAll`, per-slot verification, and `IsFullySigned`. `TransactionMessageHash` + exposes the Rust SDK's domain-separated BLAKE3 message identifier. + Money-critical encodings are checked byte-for-byte against pinned Rust or independently generated + compatible vectors, not only against local round trips. ```csharp using SolSharp.Programs; @@ -233,7 +318,11 @@ var signature = await rpc.SendTransactionAsync(tx.Serialize()); ## Requirements -- .NET 8 SDK or later. +- .NET 8 SDK or later. `global.json` selects the lowest available compatible major beginning at + .NET 8, so CI proves the minimum while newer local SDKs remain usable. +- Calling the BLS12-381 API requires one of the native RIDs shipped by `Nethermind.Crypto.Bls` 1.0.5: + `linux-x64`, `linux-arm64`, `osx-x64`, `osx-arm64`, or `win-x64`. All non-BLS SolSharp APIs remain + managed and do not load that native backend. ## Build & test @@ -271,23 +360,42 @@ SOLSHARP_RPC_URL=https://your-node SOLSHARP_WS_URL=wss://your-node \ ``` SolSharp/ - src/SolSharp.Core/ Encoding/ Primitives/ Converters/ Constants/ + src/SolSharp.Core/ Encoding/ Primitives/ Converters/ Constants/ SysvarStates/ src/SolSharp.Rpc/ Protocol/ Models/ Streaming/ + client, options, DI - src/SolSharp.Wallet/ Keypair (+ parsing), ISigner, PublicKey.Verify / IsOnCurve - src/SolSharp.Programs/ instructions, PDA/ATA, Message/Transaction, TransactionBuilder + src/SolSharp.Wallet/ Ed25519/BLS keys, signers, verification, off-chain messages + src/SolSharp.Programs/ native/SPL clients and states, PDA/ATA, messages and transactions src/SolSharp/ packaging facade — bundles the four assemblies into the single NuGet package samples/ SolSharp.AotSmoke — native-compiled smoke sample, published and run in CI tests/ NUnit + FluentAssertions, mirroring each project (+ SolSharp.IntegrationTests: live-cluster read/streaming checks) benchmarks/ SolSharp.Benchmarks — standalone BenchmarkDotNet micro-benchmark harness - .github/workflows/ ci.yml (build + offline tests) and release.yml (tag → NuGet trusted publishing) + .github/workflows/ CI, coverage, dependency/security review, Scorecard, and trusted publishing assets/ package icon and README logo .editorconfig modern C# style, enforced on build + global.json .NET 8 minimum policy with local roll-forward (CI asserts SDK 8) Directory.Build.props + THIRD_PARTY_NOTICES.md exact compatibility pins and native BLS attribution CLAUDE.md conventions and decisions for contributors/agents docs/USAGE.md task-oriented usage guide with runnable examples + docs/RUST_PARITY.md pinned Rust/Agave/SPL client-contract coverage matrix ``` +## Quality gates + +The four unit-test suites' reproducible .NET 8 Linux coverage baseline across `SolSharp.Core`, +`SolSharp.Rpc`, `SolSharp.Wallet`, and `SolSharp.Programs` is +**93.7% of lines**. Build outputs under `obj/**` and generated `*.g.cs` pseudo-sources are excluded; +overlapping lower-layer hits are merged rather than counted twice. CI publishes the merged Cobertura +line/branch and Markdown reports, fails if repository-wide line coverage drops below 90%, and rejects a +documented percentage that exceeds the current measured result. + +Every pull request also receives a direct-and-transitive NuGet advisory audit and a dependency-diff +review. CodeQL runs the `security-extended` C# query suite, the dependency audit runs weekly even without +repository changes, OpenSSF Scorecard checks the repository's supply-chain posture, and release packages +receive GitHub build-provenance attestations. A green security badge means those automated checks found no +known issue at the tested revision; it is not a claim that the library is vulnerability-free or a substitute +for an independent audit. + ## Design notes - `Core` is dependency-light and free of I/O and crypto by design — anything that needs the @@ -299,9 +407,9 @@ SolSharp/ ## Security SolSharp handles private keys and builds transactions that move funds. It has **not** been -audited — use at your own risk. Never commit secrets or private keys, and never hand a raw -private key to a dependency you do not control: build with a third-party library if you must, -but sign with your own signer and simulate before sending. +audited — use at your own risk. Never commit secrets or private keys, and never export a raw +private key to an RPC provider, hosted service, or third-party transaction builder. Keep signing +behind `ISigner`, inspect and simulate externally built transactions, then send only signed bytes. To report a vulnerability, see the [security policy](SECURITY.md) — please use private reporting rather than a public issue. diff --git a/README.nuget.md b/README.nuget.md index 7028e4e..c42cb87 100644 --- a/README.nuget.md +++ b/README.nuget.md @@ -1,22 +1,69 @@ # SolSharp -A lean, modern, Native AOT-ready .NET SDK for Solana — RPC, WebSocket streaming, and -wire-level transaction signing and building. No reflection anywhere: all JSON is -source-generated, and every assembly compiles clean to a native binary. - -SolSharp is built for low latency and a small dependency footprint. If you are writing +[![Security checks](https://github.com/jecacs/SolSharp/actions/workflows/security.yml/badge.svg?branch=main)](https://github.com/jecacs/SolSharp/actions/workflows/security.yml?query=branch%3Amain) +[![CodeQL](https://github.com/jecacs/SolSharp/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/jecacs/SolSharp/actions/workflows/codeql.yml?query=branch%3Amain) +[![Unit test coverage](https://img.shields.io/badge/unit_test_coverage-93.7%25_line-brightgreen)](https://github.com/jecacs/SolSharp/blob/v2.0.0/README.md#quality-gates) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/jecacs/SolSharp/badge)](https://scorecard.dev/viewer/?uri=github.com/jecacs/SolSharp) + +A modern, contract-driven, Native AOT-ready .NET SDK for Solana — keys and signatures, +program instructions, transaction wire formats, RPC, and WebSocket streaming. SolSharp is +independently implemented in C# from pinned Anza Solana SDK, Agave, and SPL source contracts. +No reflection is used by the library: JSON is source-generated, all four functional assemblies declare +Native AOT compatibility, and CI native-publishes and runs a consumer of the packed package. + +SolSharp is built for low latency with focused dependencies and a dependency-light Core. If you are writing bots, indexers, or backend services that talk to Solana from .NET and care about speed and control, this is aimed at you. ## Why SolSharp - **Native AOT ready.** Source-generated JSON (no reflection), trimmable, AOT-clean — ship a - self-contained native binary with instant startup. CI runs a native-compiled smoke test on every push. -- **Full RPC coverage.** The complete current JSON-RPC HTTP read surface, send/simulate, - batching, and multiplexed WebSocket subscriptions with automatic reconnect. -- **Wire-level control.** Spec-accurate legacy and v0 transaction building, signing, and decoding — - every encoding checked byte-for-byte against the Rust `solana-sdk`. -- **Lean.** No kitchen-sink dependency graph; allocation-free hot paths and span-based APIs. + self-contained native binary with instant startup. CI runs a native-compiled smoke test on every push + and pull request targeting `main`. +- **Full pinned RPC coverage.** The complete applicable non-admin JSON-RPC HTTP surface from the pinned Agave revision, including reads, send/simulate, airdrop, + batching, and multiplexed WebSocket subscriptions with automatic reconnect. Explicit account/program + subscription options preserve the effective legacy binary, base58, base64, `jsonParsed` fallback, and + `base64+zstd` response union without publishing no-op Agave fields. +- **Wire-level control.** Spec-accurate legacy, v0, and feature-gated SIMD-0385 V1 transaction building, + signing, and decoding — + money-critical encodings are checked against exact vectors from pinned Rust contracts, not only + against C# round trips. +- **Complete signing workflows.** Typed Ed25519 and BLS12-381 values, local/external/null signers, + partial signing, Rust-compatible key import/export, vote-account-bound BLS proofs of possession, + PoP-gated same-message BLS aggregation, + and domain-separated Solana off-chain messages. +- **Traceable parity.** Exact upstream commit pins, coverage boundaries, and exclusions are published + in the [Rust parity matrix](https://github.com/jecacs/SolSharp/blob/v2.0.0/docs/RUST_PARITY.md); SolSharp + is independently written and is not an official Anza/Solana product. +- **Purposeful dependencies.** A dependency-light Core, allocation-free hot paths and span-based APIs; + the RPC resilience pipeline and vetted Ed25519/BLS backends are included deliberately. +- **Measured quality.** Across the four functional assemblies, the reproducible .NET 8 Linux + unit-coverage baseline covers 93.7% of hand-written production lines. CI merges overlapping reports, + excludes generated sources, publishes line and branch details, enforces a 90% repository-wide line + floor, and rejects documentation that overstates the current result. +- **Automated security gates.** Pull requests and weekly scans audit direct and transitive NuGet + advisories, review dependency changes, run CodeQL's extended C# security queries, and run OpenSSF + supply-chain checks; release packages receive GitHub build-provenance attestations. + +## Compared with Solnet and the official Rust contracts + +Solnet is an established, ecosystem-oriented .NET SDK. This compact comparison uses its +[published 8.7.0 release](https://github.com/bmresearch/Solnet/commit/e8df87bdb2006376ba3eea9e1d3b857c84fc5685); +SolSharp is release 2.0.0; the reference column is the pinned +[official Rust parity matrix](https://github.com/jecacs/SolSharp/blob/v2.0.0/docs/RUST_PARITY.md). + +| Capability | Official Rust SDK / Agave | SolSharp 2.0 | Solnet published 8.7.0 | +| --- | --- | --- | --- | +| **Transactions** | Legacy, V0, feature-gated SIMD-0385 V1 | Legacy/V0/V1 exact wire build, parse, signing, validation, and decompilation | Legacy/V0; the published decoder rejects versions above 0 | +| **RPC / PubSub** | 53 applicable request variants; nine subscription families and their effective config unions | 53/53 RPC; 9/9 PubSub, including exact HTTP/WS account-encoding unions, effective `SubscribeAccountWithOptionsAsync` / `SubscribeProgramWithOptionsAsync` configs, early signature events, and explicit V1 opt-ins | 50/53 RPC; 6/9 PubSub families | +| **Programs** | Canonical native and SPL crates | Deep native/SPL coverage, extensive Token-2022 interfaces, typed state/instruction decoders | Broader ecosystem program set; published package predates repository-head Token-2022 additions | +| **Offline signing** | Fixed slots, signer/presigner/null-signer, partial signing and verification | Typed fixed slots, partial/all signing, verified external signatures, `Presigner` / `NullSigner` | Partial signing and externally supplied signatures | +| **Deployment** | Native Rust crates | One package, generated JSON metadata, declared AOT compatibility, native-publish CI | Five modular packages; no published solution-wide AOT/trimming contract | +| **Provenance** | Authoritative source | Seven immutable upstream pins and byte-level KATs | No immutable upstream revision matrix in published documentation | + +Solnet repository head contains newer unreleased work; in particular, its current class named V1 does not yet +use the pinned SIMD-0385 message body and message-first signature envelope. The full evidence-linked comparison +is in the [repository README](https://github.com/jecacs/SolSharp/blob/v2.0.0/README.md#how-it-compares-to-solnet). ## Quick start @@ -44,25 +91,40 @@ var signature = await rpc.SendAndConfirmTransactionAsync(tx.Serialize()); ```csharp // WebSocket streaming +using SolSharp.Rpc; +using SolSharp.Rpc.Streaming; + await using var ws = new SolanaWsClient(); await ws.ConnectAsync(new Uri("wss://your-rpc-endpoint")); await foreach (var slot in ws.SubscribeSlotsAsync()) Console.WriteLine(slot.Slot); + +var accountChanges = await ws.SubscribeAccountWithOptionsAsync( + account, + new AccountSubscriptionOptions { Encoding = RpcAccountEncoding.JsonParsed }); ``` ## Learn more -- [Usage guide](https://github.com/jecacs/SolSharp/blob/main/docs/USAGE.md) — a task-oriented - cookbook: keys and mnemonic import, reads, SPL token state, priority fees, v0 + address lookup - tables, durable nonces, decoding transactions, subscriptions, batching, and confirmation. +- [Usage guide](https://github.com/jecacs/SolSharp/blob/v2.0.0/docs/USAGE.md) — a task-oriented + cookbook: keys, export, mnemonics, signed off-chain messages, reads, SPL token state, priority fees, v0 + address lookup + tables, SIMD-0385 V1, durable nonces, decoding transactions, subscriptions, batching, and confirmation. - [GitHub repository](https://github.com/jecacs/SolSharp) -- [Changelog](https://github.com/jecacs/SolSharp/blob/main/CHANGELOG.md) +- [Changelog](https://github.com/jecacs/SolSharp/blob/v2.0.0/CHANGELOG.md) +- [Upstream parity and provenance](https://github.com/jecacs/SolSharp/blob/v2.0.0/docs/RUST_PARITY.md) +- [Third-party notices](https://github.com/jecacs/SolSharp/blob/v2.0.0/THIRD_PARTY_NOTICES.md) ## Security SolSharp handles private keys and builds transactions that move funds. It has **not** been audited — -use at your own risk. Never hand a raw private key to a dependency you do not control: sign with your -own signer and simulate before sending. +use at your own risk. Never export a raw private key to an RPC provider, hosted service, or third-party +transaction builder: keep signing behind `ISigner`, inspect and simulate, then send only signed bytes. + +A green security badge means the automated checks found no known issue at the tested revision. It is not +a guarantee that the package is vulnerability-free and does not replace an independent security audit. + +BLS operations use the packaged native `blst` backend on `linux-x64`, `linux-arm64`, `osx-x64`, +`osx-arm64`, and `win-x64`. Other RIDs can use the rest of SolSharp, but cannot call its BLS API. To report a vulnerability, use the [security policy](https://github.com/jecacs/SolSharp/blob/main/SECURITY.md) — private reporting, diff --git a/SECURITY.md b/SECURITY.md index c7499cb..88474b8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,10 +7,11 @@ taken seriously and are appreciated. | Version | Supported | | ------- | --------- | +| 2.x | ✅ | | 1.x | ✅ | | < 1.0 | ❌ | -Fixes ship as a patch release of the latest 1.x version. +Fixes ship as patch releases of the latest supported 2.x and 1.x lines. ## Reporting a vulnerability @@ -27,6 +28,19 @@ You can expect an acknowledgment within a few days. Please allow a fix and a pat before public disclosure; you will be credited in the advisory and the changelog unless you prefer otherwise. +## Automated assurance + +- Pull requests and `main` are checked against direct and transitive NuGet advisories; low through + critical findings fail the security gate. +- Dependency Review rejects pull requests that introduce a known vulnerable dependency, and the + advisory audit also runs weekly so newly published advisories are detected without a new commit. +- CodeQL runs the extended C# security query suite. OpenSSF Scorecard reports repository supply-chain + posture, and release packages receive GitHub build-provenance attestations after the exact packed + artifact passes the Native AOT smoke test. + +These checks report known issues in the inputs and databases available at run time. A green badge is +useful evidence, but it is not proof that SolSharp is vulnerability-free and is not a professional audit. + ## Scope notes - SolSharp has **not** been professionally audited — treat it accordingly and simulate before @@ -34,6 +48,9 @@ prefer otherwise. - The Ed25519 engine is [BouncyCastle.Cryptography](https://www.nuget.org/packages/BouncyCastle.Cryptography); vulnerabilities in BouncyCastle itself should be reported upstream, but a SolSharp report is still welcome so the dependency can be bumped quickly. +- BLS12-381 operations use [Nethermind.Crypto.Bls](https://www.nuget.org/packages/Nethermind.Crypto.Bls) + and its packaged `blst` native backend. Backend or RID-specific failures are in scope for SolSharp; + upstream cryptographic vulnerabilities should also be reported to the dependency maintainer. - Key handling promises that *are* in scope: `Keypair` zeroes its secret on dispose/finalization, secrets never appear in logs or exception messages, and nothing in the library transmits key material anywhere. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..f26eea8 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,58 @@ +# Third-party notices and compatibility sources + +SolSharp is an independently written C# implementation. It does not bundle Rust binaries +or source code from the projects below. Their public wire formats, data layouts, validation +rules, and known-answer vectors are used as the authoritative compatibility contracts for +SolSharp. + +The following repositories are licensed under the +[Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0): + +| Project | Copyright holder/project | Source revision used for compatibility | +| --- | --- | --- | +| [Solana SDK](https://github.com/anza-xyz/solana-sdk) | Anza and Solana contributors | `ec7a0467e268774b724d55120ad952b518f27d64` | +| [Agave](https://github.com/anza-xyz/agave) | Anza and Solana contributors | `ab6553293094e59dee7d3e7c928c7fa1023d0684` | +| [SPL Token](https://github.com/solana-program/token) | Solana Program Library contributors | `dbd89438108fda6ac40866d1ccfbb85f2e7436d4` | +| [SPL Token-2022](https://github.com/solana-program/token-2022) | Solana Program Library contributors | `6d87d47d6bbde19a02521164edd72d246c5736a7` | +| [Associated Token Account](https://github.com/solana-program/associated-token-account) | Solana Program Library contributors | `5ef2d950ccdebb35a73c77e8008910cf15a87a5f` | +| [Address Lookup Table](https://github.com/solana-program/address-lookup-table) | Solana Program Library contributors | `8ebd5f4964454bc6b86d86ff191702d33c52490b` | +| [System Program](https://github.com/solana-program/system) | Solana Program Library contributors | `8c47b48e8e129ab195db3e3d2a8334dd8bbd94aa` | + +The Token-2022 pin resolves the following interface contracts. Versions below come from +that checkout's `Cargo.lock` (rather than from a floating documentation page): + +| Interface contract | Resolved version used for compatibility | +| --- | --- | +| `spl-token-interface` | `3.0.0` | +| `spl-token-2022-interface` | `3.1.1` | +| `spl-token-group-interface` | `0.7.2` | +| `spl-token-metadata-interface` | `1.0.1` | +| `spl-transfer-hook-interface` | `2.1.0` | +| `spl-tlv-account-resolution` | `0.11.1` | +| `solana-zk-elgamal-proof-interface` | `0.1.3` | +| `solana-zk-sdk-pod` | `0.1.2` | +| `spl-elgamal-registry-interface` | `0.2.1` | + +The lock file also contains older interface versions used by ancillary workspace packages; +the table records the current contracts implemented by SolSharp. Exact package checksums +remain reproducible from the pinned checkout's lock file. Tests that quote a small upstream +byte vector identify the originating contract in their name or surrounding documentation. + +## BLS runtime dependency + +SolSharp's optional-in-use BLS12-381 key and signature API is backed by +[`Nethermind.Crypto.Bls` 1.0.5](https://www.nuget.org/packages/Nethermind.Crypto.Bls/1.0.5), +an MIT-licensed .NET binding at repository commit +[`a53533fc0112f16a453f744c39cb12cecf953784`](https://github.com/NethermindEth/blst-bindings/tree/a53533fc0112f16a453f744c39cb12cecf953784). +That package carries native [Supranational `blst`](https://github.com/supranational/blst) +binaries, copyright Supranational LLC, licensed under Apache-2.0. Its package SHA-256 is +`108f09b2210ac3e95a4610379fe3c58af26d01cc9f19927e748b8196aa5d88ac`. + +Version 1.0.5 supplies native assets for Linux x64/arm64, macOS x64/arm64, and Windows x64. +It does not supply win-arm64, musl, mobile, or browser assets; applications that call the BLS +API therefore need one of the packaged native RIDs. The rest of SolSharp remains managed and +does not load `blst` unless a BLS operation is used. + +SolSharp itself is licensed under the MIT License. Nothing in this notice implies endorsement +by Anza, the Solana Foundation, or the maintainers of the referenced projects. SolSharp is not +an official Anza or Solana Foundation product. diff --git a/coverage.runsettings b/coverage.runsettings new file mode 100644 index 0000000..e7208ea --- /dev/null +++ b/coverage.runsettings @@ -0,0 +1,13 @@ + + + + + + + cobertura + **/obj/**,**/*.g.cs + + + + + diff --git a/docs/RUST_PARITY.md b/docs/RUST_PARITY.md new file mode 100644 index 0000000..24af5d9 --- /dev/null +++ b/docs/RUST_PARITY.md @@ -0,0 +1,119 @@ +# Rust SDK parity + +SolSharp is an independently implemented C# client SDK. Its wire contracts are derived +from and verified against pinned Anza Solana SDK, Agave, and Solana Program Library +sources. The pins below make every compatibility statement reproducible; parity is not +inferred from documentation pages or from another .NET SDK. + +This matrix covers functionality that belongs in an application-side SDK: keys and +signatures, address derivation, messages and transactions, instruction construction, +account decoding, JSON-RPC, and PubSub. Validator runtime, banking, consensus, gossip, +TPU, ledger storage, node administration, and command-line tools are deliberately out of +scope. + +## Pinned upstream contracts + +| Repository | Commit | Contract used by SolSharp | +| --- | --- | --- | +| [anza-xyz/solana-sdk](https://github.com/anza-xyz/solana-sdk) | `ec7a0467e268774b724d55120ad952b518f27d64` | addresses, hashes, signatures, messages, transactions, native program interfaces | +| [anza-xyz/agave](https://github.com/anza-xyz/agave) | `ab6553293094e59dee7d3e7c928c7fa1023d0684` | JSON-RPC/PubSub schemas, runtime sanitization, transaction-version feature gates | +| [solana-program/token](https://github.com/solana-program/token) | `dbd89438108fda6ac40866d1ccfbb85f2e7436d4` | classic SPL Token instructions and account layouts | +| [solana-program/token-2022](https://github.com/solana-program/token-2022) | `6d87d47d6bbde19a02521164edd72d246c5736a7` | Token-2022 base instructions, extensions, and interface crate pins | +| [solana-program/associated-token-account](https://github.com/solana-program/associated-token-account) | `5ef2d950ccdebb35a73c77e8008910cf15a87a5f` | associated-token-account instructions and derivation | +| [solana-program/address-lookup-table](https://github.com/solana-program/address-lookup-table) | `8ebd5f4964454bc6b86d86ff191702d33c52490b` | address lookup table instructions and state | +| [solana-program/system](https://github.com/solana-program/system) | `8c47b48e8e129ab195db3e3d2a8334dd8bbd94aa` | System Program instructions and generated client layouts | + +All repositories above are Apache-2.0 licensed. SolSharp remains MIT licensed; attribution +and the exact source pins shipped with the package are recorded in +`THIRD_PARTY_NOTICES.md`. + +## Status legend + +- **Compatible** — the current public API covers the applicable pinned client contract and + exact upstream vectors or equivalent cross-implementation vectors test its wire form. +- **Implemented** — the contract is present, but one or more explicitly named compatibility or + packaged-runtime validation gates remain. +- **Partial** — useful support exists, but named client-side contracts remain missing. +- **In progress** — implementation is part of the current parity release. +- **Out of scope** — server/runtime behavior that a client SDK must not reimplement. + +## Client parity matrix + +| Domain | Status | SolSharp coverage | Remaining work for the parity release | +| --- | --- | --- | --- | +| Address/PublicKey | Compatible | 32-byte value semantics, base58, JSON, byte copying, curve check | None in the pinned client contract | +| Hash | Compatible | 32-byte typed hash, base58/JSON, typed blockhash overloads | None in the pinned client contract | +| Ed25519 keypair/signature | Compatible | generation; Rust/wallet byte, base58, and `id.json` import/export; strict signing and verification; typed 64-byte signatures; RFC/upstream vectors | None in the pinned client contract | +| BLS12-381 keypair/signature | Implemented | Pinned min-pk proof-of-possession scheme, typed compressed values, vote-account derivation/signing/PoP vectors, strict subgroup/infinity validation, PoP-gated same-message aggregation | Packaged runtime execution is proven on Linux x64; execute the same package smoke on the other four advertised native RIDs | +| Signed off-chain messages | Compatible | version-0 domain, restricted/limited/extended UTF-8 formats, bounded parse, hash, sign, verify, exact upstream vectors | None in the pinned client contract | +| BIP-39 and SLIP-0010 | Compatible | Solana CLI and hardened wallet derivation paths | None in the pinned client contract | +| PDA and seeded addresses | Compatible | create/find program address, `create_with_seed`, canonical seed limits | None in the pinned client contract | +| Core encodings | Compatible | base58, canonical compact-u16, bounded Borsh primitives/collections/strings | Extend only when a public account/instruction contract requires another type | +| Sysvars and feature gates | Compatible | all current sysvar IDs; bounded Clock, Rent, EpochSchedule, EpochRewards, LastRestartSlot, SlotHashes, SlotHistory, StakeHistory and Instructions data; Feature activate/revoke clients | None in the pinned client contract | +| Legacy message | Compatible | compile, sanitize, serialize/deserialize, decompile | None | +| Message v0 + ALT | Compatible | compile, lookup extraction, sanitize, serialize/deserialize, decompile; context-slot address visibility and conservative deactivation usability | None | +| SIMD-0385 message v1 | Compatible | inline configuration, compile/sanitize/serialize/deserialize/decompile, exact pinned vectors and limits | Runtime activation remains cluster-specific | +| Transaction envelope | Compatible | version-routed legacy/v0/V1 serialization, typed signature slots, partial/full/external signing, per-slot verification, message hash, bounded allocation | None in the pinned client contract | +| System Program | Compatible | full current application-side instruction set, including nonce upgrade, seeded nonce creation, prefunded creation, and transfer-many composition | None in the pinned client contract | +| Compute Budget | Compatible | unit limit/price, heap frame, loaded-account-data limit | None | +| Address Lookup Table Program | Compatible | create, extend, freeze, deactivate, close; strict account decoding; SlotHashes-aware status, active-address prefix, and indexed lookup | None | +| Memo Program | Compatible | strict UTF-8 memo construction with signer metas | None | +| Stake and Vote | Compatible | current stable instruction families, composites, compact/tower and V2/BLS forms; bounded Stake and versioned Vote account state | None in the pinned client contract | +| Native loaders, feature gate, and precompiles | Compatible | legacy/upgradeable/V4 loader clients and states; feature activation/revocation; Ed25519/Secp256k1/Secp256r1 self-contained and offset-table clients | None in the pinned client contract | +| Classic SPL Token | Compatible | complete pinned instruction family, checked and multisig variants, fixed account/mint decoding | None in the pinned client contract | +| Associated Token Account | Compatible | create, idempotent create, recover nested, canonical derivation | None in the pinned client contract | +| Token-2022 base and non-confidential extensions | Compatible | base instructions, transfer fees, pointer/default-state/memo/CPI/interest/scaled/pausable/permissioned-burn extensions | None in the pinned client contract | +| Token metadata interface | Compatible | initialize, field update/removal, authority update, ranged emit | None in the pinned client contract | +| Token group and transfer-hook client helpers | Compatible | group/member instructions and state; validation PDA; extra-account-meta/seed codecs; bounded TLV decoding; async off-chain account resolution and de-escalation | None in the pinned client contract | +| Confidential Token-2022 client contracts | Compatible | confidential transfer/fee/mint-burn and permissioned confidential-burn instructions, raw POD proof locations, native proof-program and ElGamal-registry clients/state | ZK proof generation and ciphertext arithmetic are explicit cryptographic exclusions | +| Token/Token-2022 account decoding | Compatible | canonical base Mint/TokenAccount/multisig state, typed Token-2022 TLV/extensions, metadata/group/hook/registry state, and instruction decoders | None in the pinned client contract | +| HTTP JSON-RPC | Compatible | Every non-admin, non-obsolete method in the pinned `RpcRequest` surface; exact account-data encoding union and effective context/filter/slice/detail config variants; bounded responses, batching, typed errors, explicit V1 raw/parsed opt-ins and parsed V1 configuration | None in the pinned client contract | +| WebSocket PubSub | Compatible | Full pinned subscription families; `SubscribeAccountWithOptionsAsync` / `SubscribeProgramWithOptionsAsync` expose effective configs and the exact legacy binary/base58/base64/jsonParsed-fallback/base64+zstd union; bounded multiplexing, cancellation isolation, reconnect/replay, parsed program state, early signature receipt, and explicit V1 block opt-ins | None in the pinned client contract | +| Native AOT/trimming | Compatible | source-generated JSON, AOT annotations, package-consumer smoke app | None for managed paths; BLS native-RID execution is tracked separately above | + +For `accountSubscribe`, pinned Agave applies only encoding and commitment to notifications; +`programSubscribe` also applies filters. Both exact SolSharp methods return the closed `RpcAccountData` union: +bare legacy `binary`, tagged base58/base64/base64+zstd, or `jsonParsed`, whose unknown-program branch is a +tagged base64 tuple. The pinned PubSub config structs also contain account/program `dataSlice` and +`minContextSlot` (plus program `withContext`/`sortResults`), but Agave's subscription encoder does not apply +them. SolSharp deliberately does not publish those no-op WebSocket knobs; the corresponding HTTP options are +effective and are exposed. + +## Verification requirements + +A row may be promoted to **Compatible** only when all applicable checks pass: + +1. The implementation is compared with the pinned Rust source, including account order, + signer/writable flags, discriminators, integer widths, optional-value encoding, limits, + and sanitization behavior. +2. Money-critical wire data has an exact known-answer test from upstream or an independently + generated Rust-compatible vector; a C# encode/decode round trip alone is insufficient. +3. Malformed input and boundary behavior are tested where the Rust implementation rejects it. +4. Every public API has XML documentation and follows the repository's nested-fixture test + convention. +5. The complete solution builds with warnings as errors, all offline tests pass, formatting and + diff checks are clean, package validation succeeds, and the AOT smoke app consumes the packed + NuGet artifact. + +## Explicit exclusions + +The following Rust components are not SDK parity targets: validator/runtime execution, +Bank and AccountsDB, consensus/fork choice, gossip, Turbine/repair, TPU/QUIC services, +ledger/blockstore, snapshot creation, RPC server implementation, node-admin/deprecated +storage RPC methods, CLI binaries, test validators, and program processor code. SolSharp +constructs and decodes their public client contracts; it does not reproduce the Solana node. + +Token-2022 zero-knowledge proof generation/verification and ElGamal ciphertext arithmetic are +also outside the current managed client boundary. SolSharp constructs their exact instruction, +POD, proof-location, and account-state contracts from caller-supplied cryptographic material; it +does not substitute a home-grown proof system. SIMD-0385 V1 support likewise describes the wire +contract only: activation remains a cluster feature gate controlled by validators. + +BLS same-message aggregation is exposed only through proof-of-possession-verified public-key wrappers. +The pinned SDK's distinct-message screening path is consensus-oriented and deliberately is not presented +as a general application-security primitive by SolSharp. + +Deprecated compatibility artifacts are not promoted as current application APIs: the Stake +`Redelegate` variant was deprecated before activation and has no builder, while the legacy Fees, +Rewards, and RecentBlockhashes sysvar addresses remain available for wire identification without new +typed state decoders. Current Stake operations and nondeprecated sysvar states are covered above. diff --git a/docs/USAGE.md b/docs/USAGE.md index 760db3f..04542d0 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -3,14 +3,29 @@ A task-oriented tour of SolSharp with copy-pasteable C# examples. For the high-level overview and design notes see the [README](../README.md); for conventions and architecture see [CLAUDE.md](../CLAUDE.md). -Every snippet targets **.NET 8** and uses the single `SolSharp` NuGet package, which bundles all four -assemblies — the namespaces `SolSharp.Core.*`, `SolSharp.Rpc`, `SolSharp.Wallet`, and `SolSharp.Programs`. +Every snippet targets **.NET 8** and uses the single `SolSharp` NuGet package, which bundles four +functional assemblies plus a minimal packaging facade — the namespaces `SolSharp.Core.*`, `SolSharp.Rpc`, +`SolSharp.Wallet`, and `SolSharp.Programs`. +Unless a snippet shows a narrower import list, start with this common preamble: + +```csharp +using SolSharp.Core.Constants; +using SolSharp.Core.Encoding; +using SolSharp.Core.Primitives; +using SolSharp.Core.SysvarStates; +using SolSharp.Programs; +using SolSharp.Rpc; +using SolSharp.Rpc.Models; +using SolSharp.Rpc.Streaming; +using SolSharp.Wallet; +``` ## Contents - [Installation](#installation) - [Creating a client](#creating-a-client) - [Keys and wallets](#keys-and-wallets) +- [Signed off-chain messages](#signed-off-chain-messages) - [SOL and lamports](#sol-and-lamports) - [Reading accounts](#reading-accounts) - [SPL token accounts and mints](#spl-token-accounts-and-mints) @@ -18,8 +33,11 @@ assemblies — the namespaces `SolSharp.Core.*`, `SolSharp.Rpc`, `SolSharp.Walle - [Simulating before sending](#simulating-before-sending) - [Priority fees (compute budget)](#priority-fees-compute-budget) - [SPL token transfers](#spl-token-transfers) +- [Advanced Token-2022 interfaces](#advanced-token-2022-interfaces) - [Attaching a memo](#attaching-a-memo) +- [Native programs and account state](#native-programs-and-account-state) - [Versioned (v0) transactions and address lookup tables](#versioned-v0-transactions-and-address-lookup-tables) +- [SIMD-0385 V1 transactions](#simd-0385-v1-transactions) - [Decoding a transaction](#decoding-a-transaction) - [Reading parsed transactions](#reading-parsed-transactions) - [Cluster and validator info](#cluster-and-validator-info) @@ -35,7 +53,7 @@ assemblies — the namespaces `SolSharp.Core.*`, `SolSharp.Rpc`, `SolSharp.Walle ## Installation -SolSharp ships as one NuGet package that bundles all four assemblies: +SolSharp ships as one NuGet package that bundles four functional assemblies plus a minimal packaging facade: ```bash dotnet add package SolSharp @@ -90,7 +108,8 @@ services.AddSolanaWs(new SolanaWsClientOptions MaxReconnectAttempts = 10, ReceiveTimeout = TimeSpan.FromMinutes(2), // opt-in: only for high-frequency subscriptions MaxMessageSizeBytes = 64 * 1024 * 1024, - SubscriptionBufferCapacity = 1024 + SubscriptionBufferCapacity = 1024, + MaxPendingSubscriptionRequests = 1024 }); var ws = provider.GetRequiredService(); @@ -127,6 +146,47 @@ using var k5 = Keypair.FromBase64String(base64Secret); using var k6 = Keypair.FromSeed(thirtyTwoBytes); // just the 32-byte seed ``` +Export only when another trusted tool needs the secret. `ToBytes` returns the Solana/Rust SDK +64-byte form (seed followed by public key), while `ToJsonArray` matches `solana-keygen id.json` and +`ToBase58String` matches common wallet exports. Byte arrays can and should be cleared; strings cannot: + +```csharp +using System.Security.Cryptography; +using System.Text; + +byte[] secretKey = wallet.ToBytes(); +try +{ + // Keep plaintext key files outside the repository and restrict them to the current user. + var keyDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "solana"); + Directory.CreateDirectory(keyDirectory); + var keyPath = Path.Combine(keyDirectory, "id.json"); + var fileOptions = new FileStreamOptions + { + Mode = FileMode.CreateNew, // refuses to overwrite a file or follow an existing id.json symlink + Access = FileAccess.Write, + Share = FileShare.None + }; + if (!OperatingSystem.IsWindows()) + fileOptions.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + using (var keyFile = new FileStream(keyPath, fileOptions)) + using (var writer = new StreamWriter(keyFile, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))) + writer.Write(wallet.ToJsonArray()); + + using var imported = Keypair.FromSecretKey(secretKey); + Console.WriteLine(imported.PublicKey == wallet.PublicKey); +} +finally +{ + CryptographicOperations.ZeroMemory(secretKey); +} +``` + +`ToSeedBytes` exports only the 32-byte Ed25519 seed. Prefer byte APIs over the base58/JSON string +forms whenever possible, and never log or commit any exported value. + Import a wallet from a BIP-39 mnemonic. Two schemes exist in the wild — pick the one your source wallet uses: @@ -140,15 +200,103 @@ using var account1 = Keypair.FromMnemonicAtPath("abandon abandon … about", "m/ ``` The building blocks are public too: `Bip39.ToSeed(mnemonic, passphrase)` and -`Slip10.DeriveEd25519(seed, path)`. +`Slip10.DeriveEd25519(seed, path)`. SLIP-0010 path segments use canonical ASCII digits followed by +`'`; signs, whitespace, and non-hardened segments are rejected. -Sign and verify: +Sign and verify with either the compatibility byte-array API or the typed 64-byte `Signature` value: ```csharp byte[] message = System.Text.Encoding.UTF8.GetBytes("hello"); -byte[] signature = wallet.Sign(message); +byte[] signatureBytes = wallet.Sign(message); +Signature signature = wallet.SignSignature(message); + +bool ok = signature.Verify(wallet.PublicKey, message); // strict Ed25519 verification +bool same = wallet.PublicKey.Verify(message, signature); // equivalent Wallet extension +``` + +Verification follows Solana's strict Ed25519 rules and rejects small-order public keys and signature +points instead of accepting malleable signatures. `Signature.Parse` / `TryParse` use the base58 form +returned by Solana RPC, while `ToBytes` and `CopyTo` expose its exact 64 bytes. + +Current Vote v2/v4 contracts use the pinned minimal-public-key-size BLS12-381 proof-of-possession +scheme. Derive the BLS key from high-entropy input key material (or from an existing signer), bind its +proof to the vote account, and pass only validated typed values to the Vote builder: + +```csharp +using SolSharp.Programs; +using SolSharp.Wallet; -bool ok = wallet.PublicKey.Verify(message, signature); // Verify lives in SolSharp.Wallet +using var bls = BlsKeypair.Derive(blsInputKeyMaterial); // at least 32 high-entropy bytes +BlsProofOfPossession proof = bls.CreateVoteProofOfPossession(voteAccount); + +var initialize = new VoteInitializeV2( + node, + authorizedVoter, + bls.PublicKey, + proof, + authorizedWithdrawer, + inflationRewardsCommissionBps: 500, + blockRevenueCommissionBps: 500); + +Instruction initializeVote = VoteProgram.InitializeAccountV2( + voteAccount, + initialize, + inflationRewardsCollector, + blockRevenueCollector); +``` + +Like the pinned Rust SDK, a raw `BlsPublicKey` can validate a proof but cannot verify signatures directly. +Call `BlsKeypair.Verify` for a locally derived key, or verify the proof with +`VerifyAndWrapProofOfPossession` and call `BlsPopVerifiedPublicKey.Verify`. This keeps signer attribution and +aggregation behind an explicit proof-of-possession boundary. + +Same-message aggregation requires proof-of-possession provenance before any public key can enter the +aggregate. The typed wrapper makes the rogue-key check explicit at the API boundary: + +```csharp +using SolSharp.Wallet; + +using var firstBlsSigner = BlsKeypair.Derive(firstInputKeyMaterial); +using var secondBlsSigner = BlsKeypair.Derive(secondInputKeyMaterial); + +ReadOnlySpan registryPayload = "validator-registry"u8; +var firstVerifiedKey = firstBlsSigner.PublicKey.VerifyAndWrapProofOfPossession( + firstBlsSigner.CreateProofOfPossession(registryPayload), + registryPayload); +var secondVerifiedKey = secondBlsSigner.PublicKey.VerifyAndWrapProofOfPossession( + secondBlsSigner.CreateProofOfPossession(registryPayload), + registryPayload); + +ReadOnlySpan sharedMessage = "shared vote payload"u8; +var aggregateKey = BlsAggregatePublicKey.Aggregate([firstVerifiedKey, secondVerifiedKey]); +var aggregateSignature = BlsSignature.Aggregate( + [firstBlsSigner.Sign(sharedMessage), secondBlsSigner.Sign(sharedMessage)]); + +bool aggregateIsValid = aggregateKey.Verify(aggregateSignature, sharedMessage); +``` + +This API is deliberately for one shared message. SolSharp does not expose the pinned SDK's +consensus-oriented distinct-message screening helper as a general application-security primitive. + +`BlsKeypair.ToBytes` / `FromBytes` and `ToJsonUtf8Bytes` / `FromJsonArray(ReadOnlySpan)` use the +pinned Rust 128-byte keypair form. Both byte exports contain the secret: clear them after use. String +`ToJsonArray` / `FromJsonArray(string)` remain available for interoperability, but immutable .NET strings +cannot be zeroed and should not be the default. Compressed public keys, signatures, and proofs use strict, +fixed-length base64 text. The native BLS backend ships for `linux-x64`, `linux-arm64`, `osx-x64`, +`osx-arm64`, and `win-x64`; other RIDs must not call BLS APIs. + +For an air-gapped, hardware, or remote signing flow, wrap a signature obtained elsewhere in `Presigner`. +It re-verifies the public key and exact message on every signing request, so a signature for a different +transaction cannot be attached accidentally. `NullSigner` is the matching all-zero placeholder for an +absent required signer: + +```csharp +Signature externalSignature = Signature.Parse(base58SignatureFromHardwareWallet); +var external = new Presigner(externalPublicKey, externalSignature); +byte[] verifiedBytes = external.Sign(serializedMessage); // throws if key/message/signature do not match + +var absent = new NullSigner(cosignerPublicKey); +byte[] placeholder = absent.Sign(serializedMessage); // exactly 64 zero bytes ``` Public keys on their own: @@ -161,6 +309,44 @@ if (PublicKey.TryParse(userInput, out var key)) byte[] raw = mint.ToBytes(); // 32 bytes ``` +Blockhashes and message hashes use the distinct 32-byte `Hash` value, with the same base58 and copy APIs. +String overloads remain available for compatibility: + +```csharp +Hash recentBlockhash = Hash.Parse((await rpc.GetLatestBlockhashAsync()).Blockhash); + +var message = new TransactionBuilder() + .SetRecentBlockhash(recentBlockhash) + .SetFeePayer(wallet.PublicKey) + .AddInstruction(SystemProgram.Transfer(wallet.PublicKey, recipient, lamports)) + .BuildMessage(); +``` + +## Signed off-chain messages + +`OffchainMessage` implements the pinned Solana SDK's version-0 domain and wire format for signing a +human-readable payload without constructing a transaction. The signed bytes are domain-separated from +transaction messages and grant no on-chain authority by themselves: + +```csharp +using SolSharp.Wallet; + +using var signer = Keypair.Generate(); +var message = OffchainMessage.Create("Approve login for example.com"); + +Signature signature = message.Sign(signer); +byte[] wire = message.Serialize(); + +var received = OffchainMessage.Deserialize(wire); +bool authentic = received.Verify(signer.PublicKey, signature); +Console.WriteLine($"{received.Format}: {authentic}"); +``` + +The canonical format is selected automatically: printable ASCII up to the ledger-sized limit, bounded +UTF-8 at that limit, or extended UTF-8 up to the version-0 `ushort` wire maximum. Empty payloads, +invalid UTF-8, mismatched declared lengths, unknown versions/formats, and payloads that do not satisfy +their declared format are rejected before signing or verification. + ## SOL and lamports ```csharp @@ -174,6 +360,10 @@ ulong perSol = SolanaUnits.LamportsPerSol; // 1_000_000_000 ## Reading accounts ```csharp +using SolSharp.Core.Primitives; +using SolSharp.Rpc; +using SolSharp.Rpc.Models; + var account = PublicKey.Parse("…"); ulong lamports = await rpc.GetBalanceAsync(account); @@ -184,6 +374,7 @@ if (info is not null) Console.WriteLine($"owner: {info.Owner}"); Console.WriteLine($"lamports: {info.Lamports}"); Console.WriteLine($"data: {info.Data.Length} bytes"); // already base64-decoded + Console.WriteLine($"full size: {info.Space} bytes"); // still the full size when DataSlice was used } // Several at once (order preserved; missing accounts come back null): @@ -191,15 +382,81 @@ IReadOnlyList many = await rpc.GetMultipleAccountsAsync([accountA, // Fetch only a slice of a large account (e.g. the first 8 bytes, an Anchor discriminator): var head = await rpc.GetAccountInfoAsync(account, dataSlice: new DataSlice(0, 8)); + +// The convenient base64 path can also preserve the response context and protect the read +// from being evaluated before a known slot: +var contextual = await rpc.GetAccountInfoWithContextAsync( + account, + new GetAccountInfoOptions + { + Commitment = Commitment.Confirmed, + DataSlice = new DataSlice(0, 8), + MinContextSlot = lastObservedSlot + }); +Console.WriteLine($"evaluated at slot {contextual.Context.Slot}"); + +// The exact path preserves every upstream account-data branch, including base58, +// base64+zstd and jsonParsed (whose unknown-program fallback is a base64 tuple): +var exactResponse = await rpc.GetAccountInfoWithOptionsAndContextAsync( + account, + new RpcAccountInfoOptions + { + Encoding = RpcAccountEncoding.JsonParsed, + Commitment = Commitment.Confirmed, + MinContextSlot = lastObservedSlot + }); +var exact = exactResponse.Value; +Console.WriteLine($"exact branch evaluated at slot {exactResponse.Context.Slot}"); + +if (exact?.Data is RpcAccountData.Parsed parsed) + Console.WriteLine($"{parsed.Program}: {parsed.Value}"); +else if (exact?.Data is RpcAccountData.Encoded encoded) + Console.WriteLine($"fallback encoding: {encoded.Encoding}"); +else if (exact?.Data is RpcAccountData.LegacyBinary legacy) + Console.WriteLine($"legacy base58: {legacy.EncodedData}"); ``` -`GetProgramAccountsAsync` scans every account a program owns, narrowed by memcmp / data-size filters, and +Use `GetAccountInfoWithOptionsAsync` when the exact data branch matters but the context does not. The +multiple-account, program-account, owner-filter, and delegate-filter exact reads follow the same naming: +their `WithOptionsAsync` variants return values directly, while `WithOptionsAndContextAsync` preserves +the upstream `{ context, value }` wrapper. + +`GetProgramAccountsAsync` scans every account a program owns, narrowed by the full upstream filter union, and takes the same `DataSlice` (via `GetProgramAccountsOptions.DataSlice`) to trim large result sets: ```csharp +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; +using SolSharp.Rpc; + +var programId = PublicKey.Parse(SolanaProgramIds.TokenProgram); +var mintBytesBase58 = PublicKey.Parse(Mints.WrappedSol).ToString(); +var ownerBytesBase64 = Convert.ToBase64String( + PublicKey.Parse(SolanaProgramIds.SystemProgram).ToBytes()); + var accounts = await rpc.GetProgramAccountsAsync( programId, - new GetProgramAccountsOptions { Filters = [AccountFilter.DataSize(165)] }); + new GetProgramAccountsOptions + { + Filters = + [ + AccountFilter.DataSize(165), + AccountFilter.MemoryCompareBase58(0, mintBytesBase58), + AccountFilter.MemoryCompareBase64(32, ownerBytesBase64), + AccountFilter.TokenAccountState() + ] + }); + +// Preserve the response context and ask the node to return deterministic balance ordering: +var contextualAccounts = await rpc.GetProgramAccountsWithContextAsync( + programId, + new GetProgramAccountsOptions + { + Filters = [AccountFilter.DataSize(165)], + DataSlice = new DataSlice(0, 32), + MinContextSlot = lastObservedSlot, + SortResults = true + }); ``` For a program that uses Anchor / Borsh layout, pair `getAccountInfo` with Core's `BorshReader`: @@ -209,12 +466,14 @@ using SolSharp.Core.Encoding; var info = await rpc.GetAccountInfoAsync(account) ?? throw new InvalidOperationException("account not found"); +var (authority, owner, initialized) = DecodeAccount(info.Data); -var reader = new BorshReader(info.Data); -reader.Skip(8); // Anchor 8-byte discriminator -ulong authority = reader.ReadU64(); -PublicKey owner = reader.ReadPublicKey(); -bool initialized = reader.ReadBool(); +static (ulong Authority, PublicKey Owner, bool Initialized) DecodeAccount(ReadOnlySpan data) +{ + var reader = new BorshReader(data); + reader.Skip(8); // Anchor 8-byte discriminator + return (reader.ReadU64(), reader.ReadPublicKey(), reader.ReadBool()); +} ``` `BorshWriter` is the inverse — build Anchor / Borsh instruction data (an 8-byte discriminator, then the args): @@ -233,6 +492,11 @@ byte[] data = writer.ToArray(); // feed to new Instruction { ..., Data = d SolSharp decodes the SPL Token `Pack` layout into typed records. ```csharp +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; +using SolSharp.Rpc; +using SolSharp.Rpc.Models; + var usdc = PublicKey.Parse("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); var mint = await rpc.GetMintAsync(usdc); @@ -264,6 +528,11 @@ foreach (var entry in owned) // Same shape for accounts approved to a delegate: var delegated = await rpc.GetTokenAccountsByDelegateAsync(delegateKey, usdc); +// Or scan every classic-Token account owned by the wallet, without fixing one mint: +var everyClassicTokenAccount = await rpc.GetTokenAccountsByOwnerWithFilterAsync( + owner, + TokenAccountsFilter.ByProgramId(PublicKey.Parse(SolanaProgramIds.TokenProgram))); + // The mint's total supply as a UI amount: var supply = await rpc.GetTokenSupplyAsync(usdc); Console.WriteLine($"{supply.UiAmountString} ({supply.Decimals} decimals)"); @@ -336,10 +605,18 @@ node's own `finalized` default a just-fetched blockhash may not exist yet, and p Need devnet test funds first? ```csharp -await rpc.RequestAirdropAsync(payer.PublicKey, SolanaUnits.LamportsPerSol); +await rpc.RequestAirdropWithOptionsAsync( + payer.PublicKey, + SolanaUnits.LamportsPerSol, + new RequestAirdropOptions + { + Commitment = Commitment.Confirmed, + RecentBlockhash = blockhash + }); ``` `SystemProgram` covers more than transfers: `CreateAccount(from, newAccount, lamports, space, owner)`, +`CreateAccountAllowPrefund` for an already funded destination, `TransferMany` for composable fan-out, `Allocate` / `Assign`, the seed-derived variants (`CreateAccountWithSeed`, `AllocateWithSeed`, `AssignWithSeed`, `TransferWithSeed`), and the durable-nonce instruction set (see [Durable nonces](#durable-nonces)). @@ -349,7 +626,14 @@ await rpc.RequestAirdropAsync(payer.PublicKey, SolanaUnits.LamportsPerSol); Dry-run a transaction to read its logs and compute-unit cost without paying a fee. ```csharp -var sim = await rpc.SimulateTransactionAsync(tx.Serialize()); +var sim = await rpc.SimulateTransactionAsync( + tx.Serialize(), + new SimulateTransactionOptions + { + Accounts = [payer.PublicKey], + AccountsEncoding = RpcAccountEncoding.JsonParsed, + InnerInstructions = true + }); Console.WriteLine($"compute units: {sim.UnitsConsumed}"); foreach (var line in sim.Logs ?? []) @@ -357,11 +641,18 @@ foreach (var line in sim.Logs ?? []) if (sim.IsError) Console.WriteLine($"would fail: {sim.Err}"); + +Console.WriteLine($"fee: {sim.Fee}; loaded bytes: {sim.LoadedAccountsDataSize}"); +Console.WriteLine($"returned accounts: {sim.Accounts?.Count ?? 0}"); +Console.WriteLine($"CPI groups: {sim.InnerInstructions?.Count ?? 0}"); ``` `SimulateTransactionOptions` controls the run (`SigVerify`, `ReplaceRecentBlockhash`, `Commitment`, -`MinContextSlot`); like preflight, the simulation runs at `confirmed` by default so a blockhash fetched -with `GetLatestBlockhashAsync` is visible to it. +`MinContextSlot`, `Accounts`, `AccountsEncoding`, `InnerInstructions`). Account snapshots preserve the +node's exact base64, base64+zstd, or jsonParsed branch through `RpcAccountData`; like preflight, the simulation runs at `confirmed` by +default so a blockhash fetched with `GetLatestBlockhashAsync` is visible to it. When the node reports them, +the result also preserves replacement blockhashes, pre/post SOL and token balances, loaded addresses, +program return data, and cost details. ## Priority fees (compute budget) @@ -435,16 +726,40 @@ var tx = new TransactionBuilder() await rpc.SendAndConfirmTransactionAsync(tx.Serialize()); ``` -The full op set is available: `Transfer` / `TransferChecked`, `MintTo` / `MintToChecked`, +The supported builders include `Transfer` / `TransferChecked`, `MintTo` / `MintToChecked`, `Burn` / `BurnChecked`, `Approve` / `ApproveChecked`, `Revoke`, `SetAuthority` (pick the authority with `AuthorityType`; pass no new authority to remove it permanently), `FreezeAccount` / `ThawAccount`, `InitializeMint`, `InitializeAccount`, `CloseAccount`, `SyncNative` — plus `AssociatedTokenAccount.Create` and `CreateIdempotent`. +Authority-bearing builders also have SPL Multisig overloads. Pass the multisig account as `authority` +and the member public keys in the same order as their signer account metas; the multisig account itself +is deliberately not marked as a signer. SPL Token permits between 1 and 11 supplied member signers: + +```csharp +var transfer = TokenProgram.TransferChecked( + source, + mint, + destination, + authority: multisigAccount, + amount: 1_000_000, + decimals: decimals, + tokenProgram: null, + multisigSigners: [memberA.PublicKey, memberB.PublicKey]); + +var tx = new TransactionBuilder() + .SetRecentBlockhash(blockhash) + .AddInstruction(transfer) + .Build(payer, memberA, memberB); +``` + `AuthorityType` also carries the Token-2022 extension authorities (`TransferFeeConfig`, `CloseMint`, -`PermanentDelegate`, `MetadataPointer`, ...), valid when the instruction targets the Token-2022 program: +`PermanentDelegate`, `MetadataPointer`, ...), valid when the instruction targets the Token-2022 program. +Passing one while targeting classic SPL Token is rejected before an invalid instruction can be built: ```csharp +using SolSharp.Core.Constants; + TokenProgram.SetAuthority( mint, currentAuthority, AuthorityType.TransferFeeConfig, newAuthority, tokenProgram: PublicKey.Parse(SolanaProgramIds.Token2022Program)); @@ -466,6 +781,175 @@ var ix = TokenProgram.TransferChecked(source, mint, destination, owner, 1_000_00 var ata = AssociatedTokenAccount.GetAddress(owner, mint, token2022); // matching ATA derivation ``` +`AssociatedTokenAccount.RecoverNested` moves tokens out of an accidentally nested ATA and closes the +nested account using the canonical owner/mint derivation; its owner mint must itself be the wallet's ATA. + +## Advanced Token-2022 interfaces + +The pinned Token-2022 client contracts include extension allocation/initialization, transfer fees, +metadata, token groups, transfer hooks, confidential-transfer/fee/mint-burn instruction families, +native proof-program PODs, and the ElGamal registry. Group and member values have fixed typed decoders: + +```csharp +Instruction growAccount = Token2022Program.Reallocate( + tokenAccount, + payer.PublicKey, + owner.PublicKey, + [Token2022ExtensionType.MemoTransfer, Token2022ExtensionType.CpiGuard]); + +Instruction configureTransferFees = Token2022Program.InitializeTransferFeeConfig( + mint, + transferFeeAuthority.PublicKey, + withdrawWithheldAuthority.PublicKey, + basisPoints: 25, + maximumFee: 1_000_000); + +Instruction initializeMetadata = Token2022Program.InitializeTokenMetadata( + metadata: mint, + updateAuthority: metadataAuthority.PublicKey, + mint: mint, + mintAuthority: mintAuthority.PublicKey, + name: "Example Token", + symbol: "EX", + uri: "https://example.invalid/token.json"); + +Instruction pointAtMetadata = Token2022Program.InitializeMetadataPointer( + mint, + authority: metadataAuthority.PublicKey, + metadataAddress: mint); +``` + +Extension initializers for a new mint must be placed before the base `TokenProgram.InitializeMint` +instruction; `Reallocate` is for extending an existing token account and requires its payer and owner +signatures. + +```csharp +using SolSharp.Programs; + +Instruction initializeGroup = Token2022Program.InitializeTokenGroup( + groupAccount, + groupMint, + mintAuthority.PublicKey, + updateAuthority: groupAuthority.PublicKey, + maximumSize: 10_000); + +byte[] groupBytes = (await rpc.GetAccountInfoAsync(groupAccount))!.Data; +TokenGroupState group = TokenGroupState.Decode(groupBytes) + ?? throw new InvalidOperationException("Malformed token-group state"); +Console.WriteLine($"{group.Size}/{group.MaximumSize}"); +``` + +Transfer-hook extra accounts may be literal keys or PDAs derived from instruction/account data. The +resolver follows the SPL TLV/seed contract in order, de-escalates duplicate privileges, and appends the +hook program plus validation PDA to a Token-2022 transfer: + +```csharp +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; +using SolSharp.Programs; + +async ValueTask?> FetchAccountData(PublicKey address, CancellationToken ct) + => (await rpc.GetAccountInfoAsync(address, cancellationToken: ct))?.Data; + +var token2022 = PublicKey.Parse(SolanaProgramIds.Token2022Program); +var transfer = TokenProgram.TransferChecked( + source, mint, destination, owner.PublicKey, amount, decimals, token2022); + +Instruction transferWithHookAccounts = await TransferHookProgram.AddExtraAccountsForExecuteAsync( + transfer, + hookProgramId, + source, + mint, + destination, + owner.PublicKey, + amount, + FetchAccountData); +``` + +Confidential builders intentionally accept exact caller-generated ciphertext/proof PODs through +`ConfidentialProofLocation` and `ElGamalProofProgram`. SolSharp validates widths, instruction offsets, +accounts, and discriminators, but does not claim to generate or verify zero-knowledge proofs locally; +use a compatible audited cryptographic provider for those bytes. + +For example, a public-key-validity proof can be verified immediately before creating its registry. The +relative offset is measured from the registry instruction, so `-1` refers to the preceding verifier: + +```csharp +Instruction verifyRegistryKey = ElGamalProofProgram.VerifyProof( + ElGamalProofInstruction.VerifyPubkeyValidity, + publicKeyValidityProofData); +Instruction createRegistry = ElGamalRegistryProgram.CreateRegistry( + owner.PublicKey, + ConfidentialProofLocation.AtInstructionOffset(-1)); + +Instruction[] registrySetup = [verifyRegistryKey, createRegistry]; + +var registryAddress = ElGamalRegistryProgram.GetRegistryAddress(owner.PublicKey); +byte[] registryBytes = (await rpc.GetAccountInfoAsync(registryAddress))!.Data; +ElGamalRegistryState registry = ElGamalRegistryProgram.DecodeState(registryBytes) + ?? throw new InvalidOperationException("Malformed ElGamal registry state"); +``` + +Ordinary confidential transfers and withdrawals use the same proof-location model. In this example every +proof has already been verified into a context-state account; use signed relative offsets instead when the +proof instructions are composed into the same transaction: + +```csharp +Instruction confidentialTransfer = Token2022Program.TransferConfidentialTokens( + source, + mint, + destination, + newSourceDecryptableAvailableBalance, + auditorCiphertextLow, + auditorCiphertextHigh, + owner.PublicKey, + ConfidentialProofLocation.AtContextState(equalityContext), + ConfidentialProofLocation.AtContextState(validityContext), + ConfidentialProofLocation.AtContextState(rangeContext)); + +Instruction confidentialWithdraw = Token2022Program.WithdrawConfidentialTokens( + source, + mint, + amount, + decimals, + newSourceDecryptableAvailableBalance, + owner.PublicKey, + ConfidentialProofLocation.AtContextState(equalityContext), + ConfidentialProofLocation.AtContextState(rangeContext)); +``` + +Permissioned confidential burns keep the mint's permissioned-burn authority distinct from the token +account owner. Proofs may be referenced by signed relative instruction offsets or by pre-verified context +accounts; any proof-verification instructions still have to be composed into the transaction separately: + +```csharp +using SolSharp.Core.Primitives; +using SolSharp.Programs; + +static Instruction BuildPermissionedConfidentialBurn( + PublicKey tokenAccount, + PublicKey mint, + PublicKey permissionedBurnAuthority, + PublicKey owner, + ReadOnlySpan newDecryptableAvailableBalance, + ReadOnlySpan auditorCiphertextLow, + ReadOnlySpan auditorCiphertextHigh, + PublicKey equalityContext, + PublicKey validityContext, + PublicKey rangeContext) + => Token2022Program.BurnPermissionedConfidentialTokens( + tokenAccount, + mint, + permissionedBurnAuthority, + newDecryptableAvailableBalance, + auditorCiphertextLow, + auditorCiphertextHigh, + owner, + ConfidentialProofLocation.AtContextState(equalityContext), + ConfidentialProofLocation.AtContextState(validityContext), + ConfidentialProofLocation.AtContextState(rangeContext)); +``` + ## Attaching a memo ```csharp @@ -476,6 +960,163 @@ var tx = new TransactionBuilder() .Build(payer); ``` +## Native programs and account state + +The native client layer follows the pinned System, Stake, Vote, loader, Compute Budget, ALT, Memo, and +signature-precompile contracts. Composite helpers return instruction arrays without hiding the signers +that must be supplied to `TransactionBuilder`: + +```csharp +Instruction[] payouts = SystemProgram.TransferMany( + payer.PublicKey, + (recipient, lamports), + (feeCollector, feeLamports)); + +Instruction initializePrefunded = SystemProgram.CreateAccountAllowPrefund( + prefundedAccount.PublicKey, + space: accountDataLength, + owner: targetProgramId); + +Instruction[] createSeededNonce = SystemProgram.CreateNonceAccountWithSeed( + payer.PublicKey, + nonceAccount, + nonceBase.PublicKey, + seed: "durable-nonce", + authority: nonceAuthority, + lamports: nonceRent); + +Instruction recoverNestedAta = AssociatedTokenAccount.RecoverNested( + payer.PublicKey, + ownerMint, + nestedMint, + tokenProgram: token2022); +``` + +`TransferMany` and seeded-nonce helpers return multiple ordinary instructions; add all of them to the +builder and supply every signer identified above. `RecoverNested` derives the three canonical ATA +addresses and emits the current one-byte instruction tag. + +```csharp +using SolSharp.Programs; +using SolSharp.Wallet; + +using var stakeAccount = Keypair.Generate(); +var authorities = new StakeAuthorized(payer.PublicKey, payer.PublicKey); +var noLockup = new StakeLockup(UnixTimestamp: 0, Epoch: 0, Custodian: default); + +Instruction[] createAndDelegate = StakeProgram.CreateAccountAndDelegateStake( + payer.PublicKey, + stakeAccount.PublicKey, + voteAccount, + authorities, + noLockup, + stakeLamports); + +var tx = new TransactionBuilder() + .SetRecentBlockhash(blockhash) + .AddInstructions(createAndDelegate) + .Build(payer, stakeAccount); +``` + +Loader helpers likewise preserve every signer and wire step. This creates a Loader V4 account, writes one +ELF chunk, and constructs the deploy instruction; the caller still chooses funding, chunking, simulation, +and transaction boundaries: + +```csharp +using var programAccount = Keypair.Generate(); +Instruction[] createProgram = LoaderV4Program.CreateBuffer( + payer.PublicKey, + programAccount.PublicKey, + programLamports, + programAuthority.PublicKey, + programLength: checked((uint)elfBytes.Length), + recipient: payer.PublicKey); +Instruction writeProgram = LoaderV4Program.Write( + programAccount.PublicKey, + programAuthority.PublicKey, + offset: 0, + bytes: elfBytes); +Instruction deployProgram = LoaderV4Program.Deploy( + programAccount.PublicKey, + programAuthority.PublicKey); +``` + +`UpgradeableBpfLoaderProgram` exposes the corresponding buffer, deploy, upgrade, authority, extend, close, +and ProgramData-PDA operations for Loader V3. `FeatureGateProgram.ActivateWithLamports` and +`RevokePendingActivation` mirror the governance-facing feature interface; they do not grant authority to +activate arbitrary cluster features. + +Account decoders reject wrong sizes, option tags, discriminators, alignment, and hostile collection +counts before allocation. For example: + +```csharp +byte[] stakeBytes = (await rpc.GetAccountInfoAsync(stakeAccountAddress))!.Data; +StakeAccountState stake = StakeAccountState.Parse(stakeBytes); + +byte[] loaderBytes = (await rpc.GetAccountInfoAsync(programDataAddress))!.Data; +UpgradeableBpfLoaderState loader = UpgradeableBpfLoaderState.Parse(loaderBytes); + +byte[] voteBytes = (await rpc.GetAccountInfoAsync(voteAccount))!.Data; +VoteStateVersions voteState = VoteStateVersions.Parse(voteBytes); +Console.WriteLine($"{voteState.Version}: {voteState.Votes.Count} tower entries"); +``` + +Current sysvar IDs live in `Sysvars`; their bounded account decoders live in +`SolSharp.Core.SysvarStates`. Fetch and validate the account owner before parsing its exact layout: + +```csharp +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; +using SolSharp.Core.SysvarStates; + +var clockAccount = await rpc.GetAccountInfoAsync(PublicKey.Parse(Sysvars.Clock)); +if (clockAccount is null || clockAccount.Owner != PublicKey.Parse(Sysvars.Owner)) + throw new InvalidOperationException("Clock sysvar is missing or has the wrong owner."); + +ClockSysvarState clock = ClockSysvarState.Parse(clockAccount.Data); +Console.WriteLine($"slot {clock.Slot}, epoch {clock.Epoch}"); +``` + +Clock, Rent, EpochSchedule, EpochRewards, LastRestartSlot, SlotHashes, SlotHistory, and StakeHistory +are decoded without unbounded collection allocations. `SlotHistorySysvarState.Check(slot)` returns +`Future`, `TooOld`, `Found`, or `NotFound` against the runtime's fixed 1,048,576-slot window. + +Precompile helpers can embed one verification payload or reference bytes in another instruction through +typed offset records. This builds the exact self-contained Ed25519 layout consumed by Agave: + +```csharp +using SolSharp.Programs; +using SolSharp.Wallet; + +using var signer = Keypair.Generate(); +byte[] payload = "authorize session"u8.ToArray(); +Signature signature = signer.SignSignature(payload); + +Instruction verify = Ed25519Program.CreateInstruction( + payload, + signature.ToBytes(), + signer.PublicKey.ToBytes()); +``` + +Secp256k1 and Secp256r1 use the same account-free precompile model. Supply signatures produced by the +appropriate external curve implementation; SolSharp constructs the exact self-contained verifier layout: + +```csharp +Instruction verifyEthereumSignature = Secp256k1Program.CreateInstruction( + payload, + compactSecp256k1Signature, + recoveryId, + ethereumAddress); +Instruction verifyPasskeySignature = Secp256r1Program.CreateInstruction( + payload, + compactLowSSecp256r1Signature, + compressedSecp256r1PublicKey); +``` + +`InstructionsSysvar.Serialize`, `ReadInstruction`, and `ReadInstructionRelative` expose the native +instruction-introspection account layout off chain, which is useful for validating those cross-instruction +offsets before submitting a transaction. + ## Versioned (v0) transactions and address lookup tables A v0 transaction can load extra accounts from an on-chain Address Lookup Table (ALT) instead of listing @@ -489,6 +1130,8 @@ var tableKey = PublicKey.Parse("…"); // Fetch + decode the table (SolSharp.Rpc model), then wrap it for the builder. var fetched = await rpc.GetAddressLookupTableAsync(tableKey) ?? throw new InvalidOperationException("lookup table not found"); +if (fetched.IsUsable is not true) + throw new InvalidOperationException("lookup table usability cannot be established without SlotHashes"); var table = new AddressLookupTableAccount(tableKey, fetched.Addresses); var tx = new TransactionBuilder() @@ -505,6 +1148,72 @@ and referenced through the table, shrinking the transaction. Building and managi with `AddressLookupTableProgram` (`CreateLookupTable`, `ExtendLookupTable`, `FreezeLookupTable` — permanently locks the table immutable, `DeactivateLookupTable`, `CloseLookupTable`). +For canonical table creation, only the payer signs; the future table authority is a read-only non-signer, +matching the currently activated Solana runtime behavior. + +`IsActive` means only that deactivation has not begun. A deactivating table remains usable during its +SlotHashes cooldown, so transaction code should inspect nullable `IsUsable`: `true` is known usable and +`null` means the RPC response lacks enough SlotHashes history to decide safely. `Addresses` is the +context-visible prefix and deliberately hides entries appended in the response slot; `StoredAddresses` +retains the full serialized list for inspection. + +When the application has fetched the SlotHashes sysvar itself, the standalone Programs decoder exposes the +exact Rust SDK decision instead of the RPC model's conservative estimate: parse `AddressLookupTableState`, +then call `GetStatus`, `IsActive`, `GetActiveAddresses`, or `Lookup` with the current slot and a decoded +`SlotHashesSysvarState`. Same-slot extensions remain hidden and a table stays active throughout cooldown. + +## SIMD-0385 V1 transactions + +V1 is the current feature-gated transaction format in the pinned Solana SDK. It stores all account addresses +inline (there are no address lookup tables), carries compute and fee settings in the message itself, begins +with `0x81`, and places its fixed number of signatures **after** the message. Build it explicitly with +`SetV1Config` and `BuildV1`: + +```csharp +using SolSharp.Programs; + +var v1 = new TransactionBuilder() + .SetRecentBlockhash(blockhash) + .SetV1Config(new TransactionConfigV1 + { + PriorityFee = 5_000, // total lamports, not micro-lamports per CU + ComputeUnitLimit = 200_000, + LoadedAccountsDataSizeLimit = 64 * 1024, + HeapSize = 32 * 1024 + }) + .AddInstruction(SystemProgram.Transfer(payer.PublicKey, recipient, lamports)) + .BuildV1(payer); + +byte[] wire = v1.Serialize(); +Console.WriteLine(v1.Version); // V1 +``` + +Do not submit an empty `TransactionConfigV1` by accident: its missing compute-unit and loaded-account-data +limits mean zero, so it is normally unusable at runtime; only the omitted heap size has a nonzero default +(32 KiB). Current V1 limits are 64 accounts, 64 instructions, 12 signatures, and a 4096-byte RPC/runtime +admission limit. The codec deliberately round-trips larger wire payloads like the pinned Rust SDK; the node +enforces admission. + +V1 is controlled by the cluster feature `enable_tx_v1` +(`SolanaFeatureIds.EnableTransactionV1`). Check activation on the target cluster before sending; library +support does not imply that a particular validator or RPC endpoint has enabled the feature: + +```csharp +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; +using SolSharp.Programs; + +var featureKey = PublicKey.Parse(SolanaFeatureIds.EnableTransactionV1); +var featureOwner = PublicKey.Parse(SolanaProgramIds.FeatureProgram); +var featureAccount = await rpc.GetAccountInfoAsync(featureKey); + +bool v1Enabled = featureAccount is not null + && featureAccount.Owner == featureOwner + && !featureAccount.Executable + && FeatureAccountState.TryParse(featureAccount.Data, out var feature) + && feature!.IsActive; +``` + ## Decoding a transaction Parse a serialized transaction (from `getTransaction`, a log, or a wallet) back into a `Transaction`. @@ -515,20 +1224,38 @@ using SolSharp.Programs; byte[] raw = Convert.FromBase64String(base64Tx); var tx = Transaction.Deserialize(raw); -Console.WriteLine(tx.Message is MessageV0 ? "versioned (v0)" : "legacy"); +Console.WriteLine(tx.Version); // Legacy, V0, or V1 Console.WriteLine($"required signers: {tx.Message.RequiredSignatures}"); foreach (var key in tx.Message.AccountKeys) Console.WriteLine(key); ``` -You can re-sign a parsed transaction (for example to add your signature to a partially signed one): only the -matching signer's slot is filled, leaving existing signatures intact. +Offline and multisig workflows use the required signer slots exposed by `RequiredSignerKeys` and `Signatures`. +`PartialSign` fills only matching slots and retains existing signatures; `AddSignature` verifies an externally +produced typed `Signature` against the exact message before inserting it. `SignAll` applies signers and fails if +any required slot remains absent. The older `Sign` name remains a compatibility alias for `PartialSign`. ```csharp -tx.Sign(payer); +using SolSharp.Core.Primitives; +using SolSharp.Wallet; + +byte[] signable = tx.GetMessageBytes(); // send these exact bytes to an external signer +Signature external = Signature.Parse(externalSignatureBase58); + +tx.PartialSign(payer) + .AddSignature(cosignerPublicKey, external); // cryptographically verified before insertion + +if (!tx.IsFullySigned || !tx.VerifySignatures()) + throw new InvalidOperationException("transaction signatures are incomplete or invalid"); + +Hash messageHash = tx.VerifyAndHashMessage(); string resubmittable = tx.ToBase64(); ``` +`VerifySignaturesWithResults()` returns one boolean per required signer, which is useful for showing exactly +which participant is still missing or invalid. A deserialized transaction retains the exact message bytes its +existing signatures cover, so later mutation of the object graph cannot silently retarget those slots. + ### Analyzing a historical transaction `getTransaction` returns the decoded bytes plus rich metadata. Parse the bytes, **decompile** the instructions @@ -539,7 +1266,7 @@ decode any failure into a typed error: var fetched = await rpc.GetTransactionAsync(signature); if (fetched is not null) { - var parsed = Transaction.Deserialize(fetched.Transaction!); + var parsed = Transaction.Deserialize(fetched.Transaction); // Resolve instructions to program ids + account keys. A v0 transaction loads accounts from lookup tables: var instructions = parsed.Message is MessageV0 v0 @@ -552,6 +1279,14 @@ if (fetched is not null) foreach (var post in fetched.Meta?.PostTokenBalances ?? []) Console.WriteLine($"{post.Mint}: {post.UiTokenAmount.UiAmountString}"); + var wireVersion = fetched.Version?.IsLegacy is true + ? "legacy" + : fetched.Version?.Number?.ToString() ?? "omitted"; + Console.WriteLine($"version={wireVersion} index={fetched.TransactionIndex} " + + $"compute={fetched.Meta?.ComputeUnitsConsumed} cost={fetched.Meta?.CostUnits}"); + if (fetched.Meta?.ReturnData is { } returned) + Console.WriteLine($"{returned.ProgramId} returned {returned.Data.Length} bytes"); + if (fetched.Meta?.Error is { } error) // typed failure reason Console.WriteLine(error.InstructionError?.CustomCode is { } code ? $"failed with program error {code}" @@ -575,6 +1310,23 @@ static async Task> FetchTablesAsync(Sol `MessageV0.GetAccountKeys(tables)` gives the full resolved account list (static + lookup-loaded), so you can map a balance entry's `accountIndex` back to a public key. +The compatibility-preserving default raw read advertises legacy/v0. To fetch V1, opt into numeric version 1; +the returned bytes can be parsed locally: + +```csharp +var v1 = await rpc.GetTransactionWithMaxVersionAsync( + signature, + maxSupportedTransactionVersion: 1, + commitment: Commitment.Confirmed) + ?? throw new InvalidOperationException("transaction not found"); +var decodedV1 = Transaction.Deserialize(v1.Transaction); +``` + +The existing method names keep their v0 maximum for source and behavior compatibility. Use the explicitly named +`GetParsedTransactionWithMaxVersionAsync`, `GetParsedBlockWithMaxVersionAsync`, +`SubscribeBlocksWithMaxVersionAsync`, and `SubscribeParsedBlocksWithMaxVersionAsync` methods when opting into +V1. A parsed V1 message exposes its nullable settings through `tx.Message.TransactionConfig`. + ### Walking an address's history, or a whole block To find the transactions in the first place, page through an address's signatures (newest first) or @@ -587,26 +1339,60 @@ var page = await rpc.GetSignaturesForAddressAsync(account, foreach (var entry in page) Console.WriteLine($"{entry.Signature} slot={entry.Slot} failed={entry.Err is not null}"); -var older = await rpc.GetSignaturesForAddressAsync(account, - new GetSignaturesForAddressOptions { Before = page[^1].Signature }); +if (page.Count > 0) +{ + var older = await rpc.GetSignaturesForAddressAsync(account, + new GetSignaturesForAddressOptions { Before = page[^1].Signature }); + Console.WriteLine($"older page: {older.Count} entries"); +} // A block's transaction signatures (feed each to GetTransactionAsync as needed): var block = await rpc.GetBlockAsync(slot); // null when the slot was skipped -foreach (var signature in block!.Signatures ?? []) - Console.WriteLine(signature); +if (block is not null) +{ + foreach (var blockSignature in block.Signatures) + Console.WriteLine(blockSignature); +} // Or every transaction in the block already decoded by the node (indexer-style): var parsedBlock = await rpc.GetParsedBlockAsync(slot); -foreach (var entry in parsedBlock!.Transactions) - Console.WriteLine($"fee: {entry.Meta?.Fee}"); +if (parsedBlock is not null) +{ + foreach (var entry in parsedBlock.Transactions) + Console.WriteLine($"fee: {entry.Meta?.Fee}"); +} + +// Schema-changing upstream choices stay lossless as JSON instead of being projected into +// one misleading model. Here only signatures and block rewards are requested: +var configuredBlock = await rpc.GetBlockWithOptionsAsync( + slot, + new GetBlockOptions + { + Encoding = RpcTransactionEncoding.Base64, + TransactionDetails = RpcTransactionDetails.Signatures, + Rewards = true, + Commitment = Commitment.Finalized, + MaxSupportedTransactionVersion = 1 + }); + +// The same exact-encoding path is available for one transaction: +var configuredTransaction = await rpc.GetTransactionWithOptionsAsync( + signature, + new GetTransactionOptions + { + Encoding = RpcTransactionEncoding.Json, + Commitment = Commitment.Confirmed, + MaxSupportedTransactionVersion = 1 + }); ``` ## Reading parsed transactions When you'd rather not Borsh-decode instructions yourself, ask the node to do it: the `jsonParsed` encoding returns recognized instructions, token balances and logs already decoded. SolSharp exposes this as a separate -read path that sits alongside the raw one. Every instruction keeps both forms — a typed `Parsed` view when the -node recognizes the program, and the raw `ProgramId` / `Accounts` / `Data` when it doesn't — so nothing is lost. +read path that sits alongside the raw one. The upstream response is a union: a recognized instruction carries +its typed `Parsed` action, while an unrecognized instruction carries raw `ProgramId` / `Accounts` / `Data`. +SolSharp preserves whichever branch the node returned without inventing fields absent from that branch. ```csharp var tx = await rpc.GetParsedTransactionAsync(signature); @@ -631,14 +1417,18 @@ if (tx is not null) `Parsed.Info` is a `JsonElement`, so you read whatever fields the specific instruction type carries: ```csharp -var transfer = tx.Message.Instructions.First(ix => ix.Parsed?.Type == "transfer"); +var parsedTx = await rpc.GetParsedTransactionAsync(signature) + ?? throw new InvalidOperationException("transaction not found"); +var transfer = parsedTx.Message.Instructions.First(ix => ix.Parsed?.Type == "transfer"); ulong lamports = transfer.Parsed!.Info.GetProperty("lamports").GetUInt64(); ``` -`GetParsedBlockAsync(slot)` returns a whole block of parsed transactions (each with its `Slot` and `BlockTime` -filled in); over the WebSocket, `SubscribeParsedBlocksAsync` streams the same parsed blocks. As with the raw -path, `GetParsedTransactionAsync` returns `null` when the signature isn't found and `GetParsedBlockAsync` -returns `null` for a skipped slot. +`GetParsedBlockAsync(slot)` returns a whole block of parsed transactions and enriches each entry with its +`Slot`, `BlockTime`, and ledger-order `TransactionIndex`. `SubscribeParsedBlocksAsync` streams the node's +parsed block payload together with `ParsedBlockNotification.Slot`; transaction-level `Slot`, `BlockTime`, +and `TransactionIndex` remain `null` in that streaming shape because PubSub does not place them on each +transaction. As with the raw path, `GetParsedTransactionAsync` returns `null` when the signature isn't found +and `GetParsedBlockAsync` returns `null` for a skipped slot. The same `jsonParsed` encoding decodes **account** state too: `GetParsedAccountInfoAsync` returns the node's typed view of a recognized account (an SPL token account or mint, a stake account, …) and falls back to raw @@ -664,8 +1454,39 @@ var schedule = await rpc.GetLeaderScheduleAsync(); // leader slots by v var nodes = await rpc.GetClusterNodesAsync(); // gossip / TPU / RPC addresses + versions var blocks = await rpc.GetBlocksAsync(startSlot, endSlot); // confirmed slots in a range +foreach (var node in nodes) + Console.WriteLine($"{node.ClientId} {node.TpuQuic} {node.Pubsub}"); + // Staking rewards paid to a set of addresses for a given epoch (null per address when there were none): var rewards = await rpc.GetInflationRewardAsync([voteAccount], epoch: 600); + +// Full variants expose every effective pinned config field without changing convenient defaults: +var context = new RpcContextOptions +{ + Commitment = Commitment.Confirmed, + MinContextSlot = lastObservedSlot +}; +var latest = await rpc.GetLatestBlockhashWithOptionsAsync(context); +var oneValidator = await rpc.GetVoteAccountsWithOptionsAsync( + new GetVoteAccountsOptions + { + VotePublicKey = voteAccount, + KeepUnstakedDelinquents = true, + DelinquentSlotDistance = 128 + }); +var oneLeader = await rpc.GetLeaderScheduleWithOptionsAsync( + new GetLeaderScheduleOptions { Identity = validator }); +var fullSupply = await rpc.GetSupplyWithOptionsAsync( + new GetSupplyOptions { ExcludeNonCirculatingAccountsList = false }); +var sortedLargest = await rpc.GetLargestAccountsWithOptionsAsync( + new GetLargestAccountsOptions + { + Filter = LargestAccountsFilter.Circulating, + SortResults = true + }); +var contextualRewards = await rpc.GetInflationRewardWithOptionsAsync( + [voteAccount], + new GetInflationRewardOptions { Epoch = 600, MinContextSlot = lastObservedSlot }); ``` Epoch structure, inflation, and network identity: @@ -675,6 +1496,7 @@ var epochSchedule = await rpc.GetEpochScheduleAsync(); // slots per epoch, var governor = await rpc.GetInflationGovernorAsync(); // inflation parameters var rate = await rpc.GetInflationRateAsync(); // current total/validator/foundation split var genesis = await rpc.GetGenesisHashAsync(); // identifies the network (mainnet/devnet/...) +var agGenesis = await rpc.GetAgGenesisCertificateAsync(); // null until Alpenglow consensus is active var identity = await rpc.GetIdentityAsync(); // the queried node's identity key var leader = await rpc.GetSlotLeaderAsync(); // current slot leader var minStake = await rpc.GetStakeMinimumDelegationAsync(); // minimum stake delegation, lamports @@ -689,6 +1511,8 @@ var commitment = await rpc.GetBlockCommitmentAsync(slot); // stake voted per c // Leader slots vs. blocks actually produced, per validator (current epoch by default): var production = await rpc.GetBlockProductionAsync(identity: validator, firstSlot: 100, lastSlot: 200); +foreach (var (validatorIdentity, counts) in production.ByIdentity) + Console.WriteLine($"{validatorIdentity}: {counts.BlocksProduced}/{counts.LeaderSlots}"); var first = await rpc.GetFirstAvailableBlockAsync(); // oldest block the node still has var minLedger = await rpc.GetMinimumLedgerSlotAsync(); // lowest slot in the node's ledger @@ -725,35 +1549,106 @@ var alive = await rpc.IsBlockhashValidAsync(blockhash); // can this blockhas ## WebSocket subscriptions All subscriptions share one connection and survive dropped connections (auto-reconnect + resubscribe). -Slots arrive as an `IAsyncEnumerable`; the rest return a `ChannelReader`. +Slots, roots, slot updates, and votes arrive as `IAsyncEnumerable`; account, program, logs, signature, +and block subscriptions return a `ChannelReader`. ```csharp using SolSharp.Core.Constants; using SolSharp.Core.Primitives; +using SolSharp.Rpc; +using SolSharp.Rpc.Models; using SolSharp.Rpc.Streaming; await using var ws = new SolanaWsClient(); await ws.ConnectAsync(new Uri("wss://api.mainnet-beta.solana.com")); +var tokenProgram = PublicKey.Parse(SolanaProgramIds.TokenProgram); // Slots: await foreach (var slot in ws.SubscribeSlotsAsync()) Console.WriteLine(slot.Slot); // Logs mentioning a program (ChannelReader): -var logs = await ws.SubscribeLogsAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); +var logs = await ws.SubscribeLogsAsync(tokenProgram); await foreach (var note in logs.ReadAllAsync()) Console.WriteLine(note.Value!.Signature); +// The exact upstream filter union also supports all non-vote logs, or all logs including votes: +var allLogs = await ws.SubscribeLogsWithFilterAsync(LogsSubscriptionFilter.AllWithVotes); + // Account changes: -var accounts = await ws.SubscribeAccountAsync(someAccount); +var accounts = await ws.SubscribeAccountAsync(tokenProgram); await foreach (var note in accounts.ReadAllAsync()) Console.WriteLine(note.Value!.Lamports); + +// Preserve the node's exact account-data branch while selecting an effective PubSub encoding: +var exactAccounts = await ws.SubscribeAccountWithOptionsAsync( + tokenProgram, + new AccountSubscriptionOptions + { + Encoding = RpcAccountEncoding.JsonParsed, + Commitment = Commitment.Confirmed + }); +await foreach (var note in exactAccounts.ReadAllAsync()) +{ + var data = note.Value!.Data; + if (data is RpcAccountData.Parsed parsed) + Console.WriteLine($"{parsed.Program}: {parsed.Value}"); + else if (data is RpcAccountData.Encoded fallback) + Console.WriteLine($"fallback: {fallback.Encoding}"); +} + +// programSubscribe applies the same encoding/commitment pair plus the full account-filter union: +var ownerBytes = PublicKey.Parse(SolanaProgramIds.SystemProgram).ToBytes(); +var programAccounts = await ws.SubscribeProgramWithOptionsAsync( + tokenProgram, + new ProgramSubscriptionOptions + { + Encoding = RpcAccountEncoding.Base64Zstd, + Commitment = Commitment.Confirmed, + Filters = + [ + AccountFilter.DataSize(165), + AccountFilter.DataSizeUnsigned(165UL), // accepts the full upstream u64 range + AccountFilter.MemoryCompareRaw(32, ownerBytes), + AccountFilter.TokenAccountState() + ] + }); + +// Ask for the optional early "receivedSignature" event, then the final processed result: +var signatureEvents = await ws.SubscribeSignatureWithOptionsAsync( + transactionSignature, + new SignatureSubscriptionOptions { EnableReceivedNotification = true }); +await foreach (var note in signatureEvents.ReadAllAsync()) + Console.WriteLine(note.Value!.Kind); + +// A schema-changing block configuration is deliberately returned as raw JSON: +var rawBlocks = await ws.SubscribeBlocksWithOptionsAsync( + BlockSubscriptionFilter.Mentions(someProgram), + new BlockSubscriptionOptions + { + Encoding = RpcTransactionEncoding.Base64, + TransactionDetails = RpcTransactionDetails.Signatures, + ShowRewards = true, + MaxSupportedTransactionVersion = 1 + }); ``` Also available: `SubscribeRootsAsync` (rooted slots, like `SubscribeSlotsAsync`), `SubscribeProgramAsync` -(with memcmp / data-size filters), `SubscribeSignatureAsync`, `SubscribeBlocksAsync`, and the `jsonParsed` -streams `SubscribeParsedBlocksAsync` / `SubscribeParsedAccountAsync`. Cancel any channel subscription by -cancelling the `CancellationToken` you pass in. +(with base58/base64/raw memcmp, unsigned data-size, and token-account-state filters), +`SubscribeSignatureAsync`, `SubscribeBlocksAsync`, and the `jsonParsed` +streams `SubscribeParsedBlocksAsync` / `SubscribeParsedAccountAsync` / `SubscribeParsedProgramAsync`. +Cancel any channel subscription by +cancelling the `CancellationToken` you pass in. A returned `ChannelReader` supports multiple concurrent +consumers; each notification is delivered to one reader. + +`RpcAccountEncoding.Binary` yields `RpcAccountData.LegacyBinary` (the bare legacy base58 string). +`Base58`, `Base64`, and `Base64Zstd` yield a tagged `RpcAccountData.Encoded`; `JsonParsed` yields +`RpcAccountData.Parsed` for a program the node recognizes and a base64 `Encoded` fallback otherwise. +Pinned Agave applies only encoding and commitment to account notifications, while program notifications also +apply filters. Shared config fields such as account/program `dataSlice` and `minContextSlot`, plus program +`withContext` and `sortResults`, are accepted but ignored by those PubSub encoders, so +`AccountSubscriptionOptions` and `ProgramSubscriptionOptions` do not pretend that they work; use the +corresponding HTTP options when those fields are required. Two more streams cover the slot lifecycle in depth — both are marked *unstable* by Solana, so their wire shape can change between node versions: @@ -773,7 +1668,13 @@ await foreach (var vote in ws.SubscribeVotesAsync()) The reconnect policy is tunable through `SolanaWsClientOptions`: `AutoReconnect` (on by default), the `ReconnectInitialDelay` → `ReconnectMaxDelay` exponential backoff, and `MaxReconnectAttempts` (`0` retries forever). When reconnect attempts are exhausted — or auto-reconnect is off — every subscription completes -with the connection error. +with the connection error. `SubscriptionAckTimeout` (30 seconds by default) bounds both initial subscribe +and reconnect replay acknowledgement waits, so one unresponsive request cannot stall every subscription +behind it. `MaxPendingSubscriptionRequests` (1,024 by default) caps the combined number of live ACK waits +and compact late-ACK cleanup records. After a timeout or cancellation, the subscription, sink, and request +parameters are released while a generation-scoped cleanup record remains so a late successful ACK can still +be unsubscribed; once the cap is reached, further subscriptions fail before they are sent until an ACK or +connection drop frees space. `ReceiveTimeout` (off by default) treats a connection with no complete message for the given interval as dropped, so auto-reconnect can replace a silently half-open socket. Only data messages reset the timer — @@ -808,6 +1709,9 @@ if (result.IsError) `SendAndConfirmTransactionAsync` wraps the send-then-poll flow and throws `TransactionFailedException` if the transaction lands but errors. +Both confirmation paths accept any non-negative timeout (or `Timeout.InfiniteTimeSpan`); long WebSocket +timeouts are chunked internally instead of hitting the platform timer limit. + ## Durable nonces A blockhash expires after roughly a minute; a durable nonce lets a transaction be signed now and submitted @@ -841,13 +1745,17 @@ await rpc.SendTransactionAsync(tx.Serialize()); `AdvanceNonceAccount` instruction, so each submission consumes the nonce exactly once. The two anchoring modes are mutually exclusive: calling `SetRecentBlockhash` afterward switches the builder back to blockhash anchoring and drops the pending `AdvanceNonceAccount`, just as `SetDurableNonce` replaces a previously set -blockhash. +blockhash. A nonce-advance-only transaction is valid too; no additional instruction is required. `CreateNonceAccount` above is a convenience pair — `CreateAccount` + `InitializeNonceAccount`, also -available separately. The rest of the nonce lifecycle is one instruction each: +available separately. `CreateNonceAccountWithSeed` returns the corresponding seeded create+initialize +pair when the nonce address was derived with `SystemProgram.CreateWithSeed`. The rest of the nonce +lifecycle is one instruction each: `SystemProgram.WithdrawNonceAccount(nonceAccount, authority, recipient, lamports)` moves lamports out of the account, and `SystemProgram.AuthorizeNonceAccount(nonceAccount, authority, newAuthority)` hands it to -a new authority. +a new authority. To migrate a legacy nonce-state account, add +`SystemProgram.UpgradeNonceAccount(nonceAccount)` to a transaction; the nonce account is writable but no +authority signature is required by that instruction. ## Program-derived addresses (PDAs) @@ -867,10 +1775,15 @@ bool onCurve = somePublicKey.IsOnCurve(); // Returns false when the result lands on the curve: if (ProgramDerivedAddress.TryCreateProgramAddress([seed, bumpSeed], programId, out var address)) Console.WriteLine(address); + +// System create_with_seed derivation is SHA-256(base || UTF-8 seed || owner). +// Unlike a PDA, its result is allowed to be on the Ed25519 curve. +var seededAddress = ProgramDerivedAddress.CreateWithSeed(baseAddress, "vault", ownerProgram); ``` A derivation accepts at most `MaxSeeds` (16) seeds — the bump counts toward the limit — of up to -`MaxSeedLength` (32) bytes each, matching the runtime's rules. +`MaxSeedLength` (32) bytes each, matching the runtime's rules. `CreateWithSeed` takes one UTF-8 seed of +at most 32 encoded bytes and enforces the System Program's reserved-owner suffix rule. ## Batching RPC calls @@ -880,7 +1793,7 @@ call's task. ```csharp var batch = rpc.CreateBatch(); -var balance = batch.GetBalanceAsync(wallet); +var balance = batch.GetBalanceAsync(wallet.PublicKey); var blockhash = batch.GetLatestBlockhashAsync(); var slot = batch.GetSlotAsync(); @@ -900,7 +1813,11 @@ allocations — or to write straight into a reusable buffer — pair `GetSeriali `TrySerialize(Span, out int)`: ```csharp -Span buffer = stackalloc byte[1232]; // the network caps a serialized transaction at 1232 bytes +using SolSharp.Programs; + +// Allocate once and reuse. Legacy/v0 admission is capped at 1232 bytes; SIMD-0385 V1 admits up to 4096. +var reusableBuffer = new byte[MessageV1.MaxTransactionSize]; +Span buffer = reusableBuffer; if (!tx.TrySerialize(buffer, out var written)) throw new InvalidOperationException("buffer too small"); @@ -912,7 +1829,7 @@ ReadOnlySpan wire = buffer[..written]; // hand to your transport without a bytes. (`SolanaRpcClient.SendTransactionAsync` takes a `byte[]`, so with the typed client plain `Serialize()` is the natural fit; the span path pays off with custom transports and pooled buffers.) -The same pattern exists one level down: `Message` / `MessageV0` (via `ITransactionMessage`) expose +The same pattern exists one level down: `Message`, `MessageV0`, and `MessageV1` (via `ITransactionMessage`) expose `GetSerializedLength()` and a span-writing `Serialize(Span)` overload for working with raw message bytes before signing. @@ -926,7 +1843,12 @@ using Microsoft.Extensions.DependencyInjection; using SolSharp.Rpc; services.AddSolanaRpc( - options => options.Endpoint = "https://your-node.example/", + options => + { + options.Endpoint = "https://your-node.example/"; + // Default: 128 MiB. Raise only when a provider returns larger legitimate block/account payloads. + options.MaximumResponseContentLength = 256 * 1024 * 1024; + }, resilience => { resilience.Retry.MaxRetryAttempts = 5; // back off harder on a busy provider @@ -936,9 +1858,17 @@ services.AddSolanaRpc( http.DefaultRequestHeaders.Add("x-api-key", apiKey)); // auth header for the provider ``` +Transient reads and replay-safe signed transaction submissions use that retry policy. `RequestAirdropAsync` +is explicitly excluded: if a node executes an airdrop but its response is lost, automatically repeating the +request would create a second airdrop. + +The response limit applies to both single and batch calls and is enforced while the HTTP body is streamed, +before the complete JSON document is buffered. + ## Error handling -- **`RpcException`** — the node returned a JSON-RPC error; `Code` and `Message` carry the details. +- **`RpcException`** — the node returned a JSON-RPC error; `Code` and `Message` carry the summary, while + `ErrorData` preserves optional structured diagnostics such as preflight logs and units consumed. - **`TransactionFailedException`** — from `SendAndConfirmTransactionAsync` when the transaction is confirmed but errored on-chain; `Signature` and the error payload are attached. - **`HttpRequestException`** — a transport-level failure or non-success status (after the resilience pipeline @@ -957,14 +1887,16 @@ catch (TransactionFailedException ex) catch (RpcException ex) { Console.WriteLine($"node rejected the request: {ex.Code} {ex.Message}"); + if (ex.ErrorData is { } data) + Console.WriteLine(data.GetRawText()); } ``` ## Publishing with Native AOT -SolSharp is fully Native AOT compatible out of the box — all JSON is source-generated (no -reflection), and every assembly is trimmable and builds clean under the trim/AOT analyzers. -No extra configuration is needed; just enable AOT in your project: +SolSharp is Native AOT compatible out of the box — all JSON is source-generated (no reflection), +and every assembly is trimmable and builds clean under the trim/AOT analyzers. No extra configuration +is needed for managed functionality; enable AOT in your project: ```xml @@ -979,16 +1911,17 @@ dotnet publish -c Release -r linux-x64 The result is a self-contained native binary with instant startup and no JIT — well suited to bots and short-lived CLI tools. A complete working example lives in [`samples/SolSharp.AotSmoke`](../samples/SolSharp.AotSmoke), which CI publishes natively and runs -on every push. +on every push and pull request targeting `main`. Two things to know when your own code meets AOT: - **Your own JSON models need their own source generation.** `SolanaJsonSerializer.Options` covers the SolSharp wire primitives only. If you serialize your own types, declare a - `JsonSerializerContext` for them; the SolSharp primitives (`PublicKey`, `Commitment`) keep their + `JsonSerializerContext` for them; the SolSharp wire primitives (`PublicKey`, `Hash`, `Commitment`) keep their wire format under any options because their mappings live in `[JsonConverter]` attributes — and the public `CoreJsonContext` can be chained into your resolver if you register models that contain them. - **Everything else is just C#.** Transaction building, signing, Borsh decoding, and the RPC/WS clients use no reflection, so no `rd.xml`, no trimmer hints, and no `DynamicDependency` - annotations are required. + annotations are required. BLS12-381 operations are the one native-backend exception: the dependency + ships AOT-compatible assets for `linux-x64`, `linux-arm64`, `osx-x64`, `osx-arm64`, and `win-x64`. diff --git a/global.json b/global.json new file mode 100644 index 0000000..441af7d --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.100", + "rollForward": "major", + "allowPrerelease": false + } +} diff --git a/samples/SolSharp.AotSmoke/Program.cs b/samples/SolSharp.AotSmoke/Program.cs index 660ee27..3bca0f8 100644 --- a/samples/SolSharp.AotSmoke/Program.cs +++ b/samples/SolSharp.AotSmoke/Program.cs @@ -4,6 +4,7 @@ // compilation and serialization, and PDA/ATA derivation. Everything runs offline against a canned // HTTP handler; any failure throws and fails the job through the non-zero exit code. using System.Net; +using System.Security.Cryptography; using System.Text; using SolSharp.Core.Primitives; using SolSharp.Programs; @@ -16,24 +17,99 @@ using var keypair = Keypair.FromSeed(new byte[32]); var message = "solsharp aot smoke"u8.ToArray(); -var signature = keypair.Sign(message); -Check(keypair.PublicKey.Verify(message, signature), "sign/verify round-trip"); +var signature = keypair.SignSignature(message); +Check(signature.Verify(keypair.PublicKey, message), "typed sign/verify round-trip"); + +var keyJson = keypair.ToJsonArray(); +using var importedKeypair = Keypair.FromJsonArray(keyJson); +Check(importedKeypair.PublicKey == keypair.PublicKey, "AOT key-file export/import"); + +var offchain = OffchainMessage.Create("solsharp aot off-chain smoke"); +var offchainSignature = offchain.Sign(keypair); +var parsedOffchain = OffchainMessage.Deserialize(offchain.Serialize()); +Check(parsedOffchain.Verify(keypair.PublicKey, offchainSignature), "off-chain message round-trip"); + +var exportedSecret = keypair.ToBytes(); +try +{ + using var importedSecret = Keypair.FromSecretKey(exportedSecret); + Check(importedSecret.PublicKey == keypair.PublicKey, "64-byte secret export/import"); +} +finally +{ + CryptographicOperations.ZeroMemory(exportedSecret); +} var recipient = PublicKey.Parse(systemProgram); +using var blsKeypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); +var blsSignature = blsKeypair.Sign(message); +var blsProof = blsKeypair.CreateVoteProofOfPossession(recipient); +Check(blsKeypair.Verify(blsSignature, message), "BLS sign/verify through PoP-verified keypair boundary"); +Check( + blsKeypair.PublicKey.VerifyVoteProofOfPossession(blsProof, recipient), + "BLS vote proof-of-possession binding"); +Check(BlsPublicKey.Parse(blsKeypair.PublicKey.ToString()).Equals(blsKeypair.PublicKey), "BLS base64 round-trip"); +using var secondBlsKeypair = BlsKeypair.Derive(Enumerable.Range(1, 32).Select(value => (byte)value).ToArray()); +var firstVerifiedBlsKey = blsKeypair.PublicKey.VerifyAndWrapProofOfPossession( + blsKeypair.CreateProofOfPossession("aot-aggregate"u8), + "aot-aggregate"u8); +var secondVerifiedBlsKey = secondBlsKeypair.PublicKey.VerifyAndWrapProofOfPossession( + secondBlsKeypair.CreateProofOfPossession("aot-aggregate"u8), + "aot-aggregate"u8); +var aggregateBlsKey = BlsAggregatePublicKey.Aggregate([firstVerifiedBlsKey, secondVerifiedBlsKey]); +var aggregateBlsSignature = BlsSignature.Aggregate([blsSignature, secondBlsKeypair.Sign(message)]); +Check(aggregateBlsKey.Verify(aggregateBlsSignature, message), "BLS same-message aggregate verification"); +var blsKeypairBytes = blsKeypair.ToBytes(); +byte[]? blsKeypairJson = null; +try +{ + blsKeypairJson = blsKeypair.ToJsonUtf8Bytes(); + using var importedBls = BlsKeypair.FromBytes(blsKeypairBytes); + using var jsonBls = BlsKeypair.FromJsonArray(blsKeypairJson); + Check(importedBls.PublicKey.Equals(jsonBls.PublicKey), "BLS keypair binary/JSON round-trip"); +} +finally +{ + CryptographicOperations.ZeroMemory(blsKeypairBytes); + if (blsKeypairJson is not null) + CryptographicOperations.ZeroMemory(blsKeypairJson); +} + +var transferInstruction = SystemProgram.Transfer(keypair.PublicKey, recipient, 1_000_000); var transactionMessage = new TransactionBuilder() .SetFeePayer(keypair.PublicKey) .SetRecentBlockhash(blockhash) - .AddInstruction(SystemProgram.Transfer(keypair.PublicKey, recipient, 1_000_000)) + .AddInstruction(transferInstruction) .BuildMessage(); var transaction = Transaction.Create(transactionMessage).Sign(keypair); var wire = transaction.Serialize(); Check(wire.Length == transaction.GetSerializedLength(), "transaction serialization length"); +Check(transaction.VerifyAndHashMessage() == transaction.GetMessageHash(), "transaction verify and message hash"); + +var v1Message = new TransactionBuilder() + .SetFeePayer(keypair.PublicKey) + .SetRecentBlockhash(blockhash) + .SetV1Config(new TransactionConfigV1 + { + ComputeUnitLimit = 200_000, + LoadedAccountsDataSizeLimit = 64 * 1024 + }) + .AddInstruction(SystemProgram.Transfer(keypair.PublicKey, recipient, 1)) + .BuildMessageV1(); +var v1Transaction = Transaction.Create(v1Message).SignAll(keypair); +var v1Wire = v1Transaction.Serialize(); +var parsedV1 = Transaction.Deserialize(v1Wire); +Check(parsedV1.Version == TransactionVersion.V1 && parsedV1.VerifySignatures(), "V1 transaction round-trip"); var (pda, _) = ProgramDerivedAddress.FindProgramAddress(["smoke"u8.ToArray()], recipient); var ata = AssociatedTokenAccount.GetAddress(keypair.PublicKey, recipient); Check(pda != default && ata != default, "PDA/ATA derivation"); +var instructionSysvar = InstructionsSysvar.Serialize([transferInstruction]); +var introspected = InstructionsSysvar.ReadInstruction(instructionSysvar, 0); +Check(introspected.ProgramId == SystemProgram.ProgramId, "Instructions sysvar round-trip"); + using var http = new HttpClient(new CannedRpcHandler()) { BaseAddress = new Uri("http://localhost") }; var client = new SolanaRpcClient(http); @@ -43,6 +119,50 @@ var account = await client.GetAccountInfoAsync(recipient); Check(account is { Lamports: 42, Data: [1, 2, 3] }, "getAccountInfo"); +var programAccounts = await client.GetProgramAccountsAsync( + recipient, + new GetProgramAccountsOptions + { + Filters = + [ + AccountFilter.MemoryCompareRaw(ulong.MaxValue, [0, 1, 2]), + AccountFilter.TokenAccountState() + ] + }); +Check(programAccounts.Count == 0, "getProgramAccounts full filter union"); + +var simulation = await client.SimulateTransactionAsync( + wire, + new SimulateTransactionOptions { Accounts = [recipient], InnerInstructions = true }); +Check( + simulation is + { + Fee: 5_000, + LoadedAccountsDataSize: 3, + Accounts: [{ Space: 3 }], + ReturnData.Data: [4, 5] + }, + "simulateTransaction current fields"); + +var agGenesis = await client.GetAgGenesisCertificateAsync(); +Check(agGenesis is null, "getAgGenesisCert nullable result"); + +var rawV1 = await client.GetTransactionWithMaxVersionAsync(signatureBase58, 1); +Check( + rawV1 is { Transaction: [0x81, 1, 2, 3] } && rawV1.Version?.Number == 1, + "raw transaction V1 opt-in"); + +var parsedRpcV1 = await client.GetParsedTransactionWithMaxVersionAsync(signatureBase58, 1); +Check( + parsedRpcV1?.Message.TransactionConfig is + { + PriorityFee: 5_000, + ComputeUnitLimit: 200_000, + LoadedAccountsDataSizeLimit: 65_536, + HeapSize: 32_768 + }, + "parsed transaction V1 config"); + var sent = await client.SendTransactionAsync(wire); Check(sent == signatureBase58, "sendTransaction"); @@ -65,6 +185,21 @@ internal sealed class CannedRpcHandler : HttpMessageHandler private const string AccountInfoJson = """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"data":["AQID","base64"],"executable":false,"lamports":42,"owner":"11111111111111111111111111111111","rentEpoch":0}},"id":1}"""; + private const string ProgramAccountsJson = + """{"jsonrpc":"2.0","result":[],"id":1}"""; + + private const string SimulateTransactionJson = + """{"jsonrpc":"2.0","result":{"context":{"slot":2,"apiVersion":"3.1.0"},"value":{"err":null,"logs":[],"accounts":[{"lamports":42,"data":["AQID","base64"],"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":0,"space":3}],"unitsConsumed":10,"loadedAccountsDataSize":3,"returnData":{"programId":"11111111111111111111111111111111","data":["BAU=","base64"]},"innerInstructions":[],"fee":5000}},"id":1}"""; + + private const string AgGenesisCertificateJson = + """{"jsonrpc":"2.0","result":null,"id":1}"""; + + private const string VersionedTransactionJson = + """{"jsonrpc":"2.0","result":{"slot":3,"blockTime":null,"transaction":["gQECAw==","base64"],"meta":null,"version":1},"id":1}"""; + + private const string ParsedVersionedTransactionJson = + """{"jsonrpc":"2.0","result":{"slot":3,"blockTime":null,"transaction":{"signatures":["5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW"],"message":{"accountKeys":[],"instructions":[],"recentBlockhash":"CktRuQ2mttgRGkXJtyksdKHjUdc2C4TgDzyB98oEzy8","transactionConfig":{"priorityFee":5000,"computeUnitLimit":200000,"loadedAccountsDataSizeLimit":65536,"heapSize":32768}}},"meta":null,"version":1},"id":1}"""; + private const string SendTransactionJson = """{"jsonrpc":"2.0","result":"5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW","id":1}"""; @@ -74,7 +209,12 @@ protected override async Task SendAsync(HttpRequestMessage var json = body switch { _ when body.Contains("getLatestBlockhash") => LatestBlockhashJson, + _ when body.Contains("getProgramAccounts") => ProgramAccountsJson, _ when body.Contains("getAccountInfo") => AccountInfoJson, + _ when body.Contains("simulateTransaction") => SimulateTransactionJson, + _ when body.Contains("getAgGenesisCert") => AgGenesisCertificateJson, + _ when body.Contains("getTransaction") && body.Contains("jsonParsed") => ParsedVersionedTransactionJson, + _ when body.Contains("getTransaction") => VersionedTransactionJson, _ when body.Contains("sendTransaction") => SendTransactionJson, _ => throw new InvalidOperationException($"Unexpected RPC request: {body}") }; diff --git a/samples/SolSharp.AotSmoke/SolSharp.AotSmoke.csproj b/samples/SolSharp.AotSmoke/SolSharp.AotSmoke.csproj index c8df6ca..ebfa6e3 100644 --- a/samples/SolSharp.AotSmoke/SolSharp.AotSmoke.csproj +++ b/samples/SolSharp.AotSmoke/SolSharp.AotSmoke.csproj @@ -8,11 +8,15 @@ $(NoWarn);CS1591 - + + + + + diff --git a/src/SolSharp.Core/Constants/SolanaFeatureIds.cs b/src/SolSharp.Core/Constants/SolanaFeatureIds.cs new file mode 100644 index 0000000..63fd68e --- /dev/null +++ b/src/SolSharp.Core/Constants/SolanaFeatureIds.cs @@ -0,0 +1,8 @@ +namespace SolSharp.Core.Constants; + +/// Well-known runtime feature account addresses from the pinned Agave feature set. +public static class SolanaFeatureIds +{ + /// The feature gate for SIMD-0385 version-1 transactions. + public const string EnableTransactionV1 = "txv1aq4pp281K9um3tnPgkfX8UqtFT6wcVW3hNezGLL"; +} diff --git a/src/SolSharp.Core/Constants/SolanaProgramIds.cs b/src/SolSharp.Core/Constants/SolanaProgramIds.cs index d470e0f..a141650 100644 --- a/src/SolSharp.Core/Constants/SolanaProgramIds.cs +++ b/src/SolSharp.Core/Constants/SolanaProgramIds.cs @@ -21,6 +21,9 @@ public static class SolanaProgramIds /// The Address Lookup Table program. public const string AddressLookupTableProgram = "AddressLookupTab1e1111111111111111111111111"; + /// The native runtime feature-gate program. + public const string FeatureProgram = "Feature111111111111111111111111111111111111"; + /// The SPL Memo program. public const string MemoProgram = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"; diff --git a/src/SolSharp.Core/Constants/Sysvars.cs b/src/SolSharp.Core/Constants/Sysvars.cs index b154105..737d8f8 100644 --- a/src/SolSharp.Core/Constants/Sysvars.cs +++ b/src/SolSharp.Core/Constants/Sysvars.cs @@ -3,15 +3,42 @@ namespace SolSharp.Core.Constants; /// Well-known Solana sysvar account addresses (base58). public static class Sysvars { + /// The owner assigned to every sysvar account. + public const string Owner = "Sysvar1111111111111111111111111111111111111"; + /// The Rent sysvar: the rent rate and exemption threshold. public const string Rent = "SysvarRent111111111111111111111111111111111"; /// The Clock sysvar: the current slot, epoch, and unix timestamp. public const string Clock = "SysvarC1ock11111111111111111111111111111111"; + /// The current epoch-rewards distribution sysvar. + public const string EpochRewards = "SysvarEpochRewards1111111111111111111111111"; + + /// The epoch schedule sysvar. + public const string EpochSchedule = "SysvarEpochSchedu1e111111111111111111111111"; + + /// The deprecated fees sysvar still exported by the current SDK. + public const string Fees = "SysvarFees111111111111111111111111111111111"; + /// The Instructions sysvar: introspection into the current transaction's instructions. public const string Instructions = "Sysvar1nstructions1111111111111111111111111"; /// The RecentBlockhashes sysvar (deprecated on-chain, still referenced by older programs). public const string RecentBlockhashes = "SysvarRecentB1ockHashes11111111111111111111"; + + /// The last cluster restart slot sysvar. + public const string LastRestartSlot = "SysvarLastRestartS1ot1111111111111111111111"; + + /// The deprecated rewards sysvar still exported by the current SDK. + public const string Rewards = "SysvarRewards111111111111111111111111111111"; + + /// The recent slot hashes sysvar. + public const string SlotHashes = "SysvarS1otHashes111111111111111111111111111"; + + /// The slot history sysvar. + public const string SlotHistory = "SysvarS1otHistory11111111111111111111111111"; + + /// The stake activation history sysvar. + public const string StakeHistory = "SysvarStakeHistory1111111111111111111111111"; } diff --git a/src/SolSharp.Core/Converters/CommitmentJsonConverter.cs b/src/SolSharp.Core/Converters/CommitmentJsonConverter.cs index bfd5722..d5dab2a 100644 --- a/src/SolSharp.Core/Converters/CommitmentJsonConverter.cs +++ b/src/SolSharp.Core/Converters/CommitmentJsonConverter.cs @@ -15,13 +15,18 @@ public sealed class CommitmentJsonConverter : JsonConverter { /// public override Commitment Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => reader.GetString() switch + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"Expected a commitment string, got {reader.TokenType}."); + + return reader.GetString() switch { "confirmed" => Commitment.Confirmed, "finalized" => Commitment.Finalized, "processed" => Commitment.Processed, var other => throw new JsonException($"Unknown commitment value: '{other}'.") }; + } /// public override void Write(Utf8JsonWriter writer, Commitment value, JsonSerializerOptions options) diff --git a/src/SolSharp.Core/Converters/CoreJsonContext.cs b/src/SolSharp.Core/Converters/CoreJsonContext.cs index b0b33b8..07662a5 100644 --- a/src/SolSharp.Core/Converters/CoreJsonContext.cs +++ b/src/SolSharp.Core/Converters/CoreJsonContext.cs @@ -13,7 +13,11 @@ namespace SolSharp.Core.Converters; /// [JsonSerializable(typeof(Commitment))] [JsonSerializable(typeof(Commitment?))] +[JsonSerializable(typeof(Hash))] +[JsonSerializable(typeof(Hash?))] +[JsonSerializable(typeof(Hash[]))] [JsonSerializable(typeof(PublicKey))] +[JsonSerializable(typeof(PublicKey?))] [JsonSerializable(typeof(PublicKey[]))] [JsonSourceGenerationOptions( GenerationMode = JsonSourceGenerationMode.Metadata, diff --git a/src/SolSharp.Core/Converters/HashJsonConverter.cs b/src/SolSharp.Core/Converters/HashJsonConverter.cs new file mode 100644 index 0000000..9be7ac8 --- /dev/null +++ b/src/SolSharp.Core/Converters/HashJsonConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SolSharp.Core.Primitives; + +namespace SolSharp.Core.Converters; + +/// +/// Reads and writes as the base58 string used for Solana blockhashes, durable +/// nonces, and message hashes. Public so source-generated consumer contexts can construct it. +/// +public sealed class HashJsonConverter : JsonConverter +{ + /// + public override Hash Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"Expected a hash string, got {reader.TokenType}."); + + var text = reader.GetString(); + if (Hash.TryParse(text, out var hash)) + return hash; + + throw new JsonException($"Invalid hash: '{text}'."); + } + + /// + public override void Write(Utf8JsonWriter writer, Hash value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); +} diff --git a/src/SolSharp.Core/Converters/PublicKeyJsonConverter.cs b/src/SolSharp.Core/Converters/PublicKeyJsonConverter.cs index 1a4cf1a..f27be0c 100644 --- a/src/SolSharp.Core/Converters/PublicKeyJsonConverter.cs +++ b/src/SolSharp.Core/Converters/PublicKeyJsonConverter.cs @@ -15,6 +15,9 @@ public sealed class PublicKeyJsonConverter : JsonConverter /// public override PublicKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException($"Expected a public-key string, got {reader.TokenType}."); + var text = reader.GetString(); if (PublicKey.TryParse(text, out var key)) return key; diff --git a/src/SolSharp.Core/Converters/SolanaJsonSerializer.cs b/src/SolSharp.Core/Converters/SolanaJsonSerializer.cs index 7eb8da5..b318a30 100644 --- a/src/SolSharp.Core/Converters/SolanaJsonSerializer.cs +++ b/src/SolSharp.Core/Converters/SolanaJsonSerializer.cs @@ -7,7 +7,8 @@ namespace SolSharp.Core.Converters; /// Shared preconfigured with SolSharp's Solana wire conventions: /// case-insensitive property matching on reads (resilience to provider casing) and null dropping on /// writes. The instance is frozen over a source-generated resolver covering the Core wire primitives -/// (, ), +/// (, , +/// ), /// so it never falls back to reflection and stays Native AOT compatible; serializing any other type with /// it throws . For custom models, create your own options - the wire /// mappings live on the types themselves (via ), so they hold their diff --git a/src/SolSharp.Core/Encoding/BorshReader.cs b/src/SolSharp.Core/Encoding/BorshReader.cs index 768791a..4e4b761 100644 --- a/src/SolSharp.Core/Encoding/BorshReader.cs +++ b/src/SolSharp.Core/Encoding/BorshReader.cs @@ -9,17 +9,11 @@ namespace SolSharp.Core.Encoding; /// fixed and length-prefixed byte sequences, UTF-8 strings, and Option / Vec prefixes. Every read is /// bounds-checked and advances the cursor. /// -public ref struct BorshReader +/// The Borsh-encoded bytes. +public ref struct BorshReader(ReadOnlySpan data) { - private readonly ReadOnlySpan _data; - - /// Creates a reader positioned at the start of . - /// The Borsh-encoded bytes. - public BorshReader(ReadOnlySpan data) - { - _data = data; - Position = 0; - } + private static readonly System.Text.Encoding StrictUtf8 = new System.Text.UTF8Encoding(false, true); + private readonly ReadOnlySpan _data = data; /// The number of bytes consumed so far. public int Position { get; private set; } @@ -77,10 +71,10 @@ public BorshReader(ReadOnlySpan data) /// There are not enough bytes left. public Int128 ReadI128() => BinaryPrimitives.ReadInt128LittleEndian(Take(16)); - /// Reads a Borsh bool: a single byte, where any non-zero value is true. + /// Reads a Borsh bool: a single byte, 0 for false or 1 for true. /// The value. - /// There are not enough bytes left. - public bool ReadBool() => Take(1)[0] != 0; + /// There are not enough bytes left, or the value is not 0 or 1. + public bool ReadBool() => ReadDiscriminant("bool"); /// Reads a 32-byte . /// The public key. @@ -89,8 +83,20 @@ public BorshReader(ReadOnlySpan data) /// Reads a length-prefixed UTF-8 string (a u32 length, then that many bytes). /// The decoded string. - /// There are not enough bytes left. - public string ReadString() => System.Text.Encoding.UTF8.GetString(Take(ReadLength())); + /// There are not enough bytes left, or the string is not valid UTF-8. + public string ReadString() + { + var bytes = Take(ReadLength()); + + try + { + return StrictUtf8.GetString(bytes); + } + catch (System.Text.DecoderFallbackException exception) + { + throw new FormatException("Borsh string contains invalid UTF-8.", exception); + } + } /// Reads raw bytes without copying. /// The number of bytes to read. @@ -118,16 +124,23 @@ public int ReadLength() return (int)length; } - /// Reads a Borsh Option tag: one byte, 0 for None and non-zero for Some. Read the value next when this returns true. + /// Reads a Borsh Option tag: one byte, 0 for None or 1 for Some. Read the value next when this returns true. /// true if a value follows (Some); false for None. - /// There are not enough bytes left. - public bool ReadOption() => Take(1)[0] != 0; + /// There are not enough bytes left, or the tag is not 0 or 1. + public bool ReadOption() => ReadDiscriminant("Option"); /// Skips bytes. /// The number of bytes to skip. /// is negative or exceeds the remaining bytes. public void Skip(int count) => Take(count); + private bool ReadDiscriminant(string type) => Take(1)[0] switch + { + 0 => false, + 1 => true, + var value => throw new FormatException($"Borsh {type} discriminant must be 0 or 1, but was {value}.") + }; + private ReadOnlySpan Take(int count) { if (count < 0 || count > Remaining) diff --git a/src/SolSharp.Core/Encoding/BorshWriter.cs b/src/SolSharp.Core/Encoding/BorshWriter.cs index 391ddb2..7522dde 100644 --- a/src/SolSharp.Core/Encoding/BorshWriter.cs +++ b/src/SolSharp.Core/Encoding/BorshWriter.cs @@ -11,6 +11,7 @@ namespace SolSharp.Core.Encoding; /// public sealed class BorshWriter { + private static readonly System.Text.Encoding StrictUtf8 = new System.Text.UTF8Encoding(false, true); private readonly ArrayBufferWriter _buffer; /// Creates an empty writer. @@ -18,6 +19,7 @@ public sealed class BorshWriter /// Creates an empty writer with a preallocated capacity. /// The number of bytes to reserve up front. + /// is less than or equal to zero. public BorshWriter(int initialCapacity) => _buffer = new ArrayBufferWriter(initialCapacity); /// The number of bytes written so far. @@ -114,10 +116,11 @@ public void WritePublicKey(PublicKey value) /// Writes a length-prefixed UTF-8 string (a u32 byte-length, then the UTF-8 bytes). /// The string to write. /// is null. + /// contains invalid UTF-16. public void WriteString(string value) { ArgumentNullException.ThrowIfNull(value); - var bytes = System.Text.Encoding.UTF8.GetBytes(value); + var bytes = StrictUtf8.GetBytes(value); WriteLength(bytes.Length); WriteBytes(bytes); } diff --git a/src/SolSharp.Core/Primitives/Hash.cs b/src/SolSharp.Core/Primitives/Hash.cs new file mode 100644 index 0000000..1a53b3f --- /dev/null +++ b/src/SolSharp.Core/Primitives/Hash.cs @@ -0,0 +1,137 @@ +using System.Buffers.Binary; +using System.Text.Json.Serialization; +using SolSharp.Core.Converters; +using SolSharp.Core.Encoding; + +namespace SolSharp.Core.Primitives; + +/// +/// A Solana hash value (32 bytes), used for blockhashes, durable nonces, and message hashes. +/// The type stores the bytes without choosing or running a hashing algorithm. +/// +[JsonConverter(typeof(HashJsonConverter))] +public readonly struct Hash : IEquatable +{ + /// The length of a Solana hash in bytes (32). + public const int Length = 32; + + private readonly ulong _a; + private readonly ulong _b; + private readonly ulong _c; + private readonly ulong _d; + private readonly string? _base58; + + /// Creates a hash from its 32 raw bytes. + /// Exactly bytes. + /// is not bytes long. + public Hash(ReadOnlySpan bytes) : this(bytes, null) + { + } + + /// Creates a hash from its base58 string form. + /// The base58-encoded hash; must decode to exactly bytes. + /// is not valid base58 or does not decode to bytes. + public Hash(string base58) : this(Decode(base58), base58) + { + } + + private Hash(ReadOnlySpan bytes, string? base58) + { + if (bytes.Length != Length) + throw new ArgumentException($"Hash must be {Length} bytes, got {bytes.Length}.", nameof(bytes)); + + _a = BinaryPrimitives.ReadUInt64LittleEndian(bytes); + _b = BinaryPrimitives.ReadUInt64LittleEndian(bytes[8..]); + _c = BinaryPrimitives.ReadUInt64LittleEndian(bytes[16..]); + _d = BinaryPrimitives.ReadUInt64LittleEndian(bytes[24..]); + _base58 = base58; + } + + /// Parses a hash from its base58 string form. + /// The base58-encoded hash; must decode to exactly bytes. + /// The parsed hash. + /// is not valid base58 or does not decode to bytes. + public static Hash Parse(string base58) => new(base58); + + /// Tries to parse a hash from its base58 string form, without throwing. + /// The base58-encoded hash, or null. + /// The parsed hash on success; otherwise. + /// true if decoded to a valid -byte hash. + public static bool TryParse(string? base58, out Hash hash) + { + if (Base58.TryDecode(base58, out var bytes) && bytes.Length == Length) + { + hash = new Hash(bytes, base58); + return true; + } + + hash = default; + return false; + } + + /// Writes the 32 raw bytes into . + /// The span to write into; must be at least bytes. + /// is smaller than bytes. + public void CopyTo(Span destination) + { + if (destination.Length < Length) + throw new ArgumentException($"Destination must be at least {Length} bytes.", nameof(destination)); + + BinaryPrimitives.WriteUInt64LittleEndian(destination, _a); + BinaryPrimitives.WriteUInt64LittleEndian(destination[8..], _b); + BinaryPrimitives.WriteUInt64LittleEndian(destination[16..], _c); + BinaryPrimitives.WriteUInt64LittleEndian(destination[24..], _d); + } + + /// Returns the 32 raw bytes of the hash as a new array. + /// A new -byte array. + public byte[] ToBytes() + { + var bytes = new byte[Length]; + CopyTo(bytes); + return bytes; + } + + /// Determines whether this hash equals . + /// The hash to compare with. + /// true if both values hold the same 32 bytes. + public bool Equals(Hash other) => _a == other._a && _b == other._b && _c == other._c && _d == other._d; + + /// + public override bool Equals(object? obj) => obj is Hash other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(_a, _b, _c, _d); + + /// Returns the base58 string form of the hash. + /// The base58-encoded hash. + public override string ToString() + { + if (_base58 is not null) + return _base58; + + Span bytes = stackalloc byte[Length]; + CopyTo(bytes); + return Base58.Encode(bytes); + } + + /// Determines whether two hashes hold the same bytes. + /// The left hash. + /// The right hash. + /// true if the hashes are equal. + public static bool operator ==(Hash left, Hash right) => left.Equals(right); + + /// Determines whether two hashes hold different bytes. + /// The left hash. + /// The right hash. + /// true if the hashes are not equal. + public static bool operator !=(Hash left, Hash right) => !left.Equals(right); + + private static byte[] Decode(string base58) + { + if (!Base58.TryDecode(base58, out var bytes)) + throw new ArgumentException($"Not a valid base58 string: '{base58}'.", nameof(base58)); + + return bytes; + } +} diff --git a/src/SolSharp.Core/SolSharp.Core.csproj b/src/SolSharp.Core/SolSharp.Core.csproj index d06aa6b..5db0d07 100644 --- a/src/SolSharp.Core/SolSharp.Core.csproj +++ b/src/SolSharp.Core/SolSharp.Core.csproj @@ -5,11 +5,11 @@ enable enable true - Core Solana primitives for .NET: PublicKey, base58 and compact-u16 (shortvec) encoding, commitment levels, and well-known program, sysvar, and mint addresses. No I/O, no crypto engine, minimal dependencies. + Core Solana primitives for .NET: distinct PublicKey and Hash values, base58, compact-u16 and bounded Borsh encoding, commitment levels, source-generated JSON, well-known addresses, and bounded current sysvar-state decoders. No I/O or crypto engine. - + diff --git a/src/SolSharp.Core/SysvarStates/CollectionSysvarStates.cs b/src/SolSharp.Core/SysvarStates/CollectionSysvarStates.cs new file mode 100644 index 0000000..99efdb2 --- /dev/null +++ b/src/SolSharp.Core/SysvarStates/CollectionSysvarStates.cs @@ -0,0 +1,90 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Core.SysvarStates; + +/// A slot and block hash from the slot-hashes sysvar. +/// The slot. +/// The slot's block hash. +public readonly record struct SlotHashEntry(ulong Slot, Hash Hash); + +/// The bounded bincode state of the slot-hashes sysvar. +public sealed record SlotHashesSysvarState +{ + /// The maximum number of entries retained by the runtime. + public const int MaximumEntries = 512; + + /// The maximum serialized account-data length. + public const int MaximumDataLength = 20_488; + + private SlotHashesSysvarState(IReadOnlyList entries) + { + Entries = entries; + } + + /// The slot hashes in their serialized order. + public IReadOnlyList Entries { get; } + + /// Decodes a bounded bincode slot-hashes account. + /// The complete account data. + /// The decoded slot hashes. + /// The data is malformed, truncated, over the runtime limit, or has trailing bytes. + public static SlotHashesSysvarState Parse(ReadOnlySpan data) + { + var reader = new SysvarBincodeReader(data); + var count = reader.ReadBoundedCount(MaximumEntries, "Slot hashes"); + var entries = new SlotHashEntry[count]; + for (var i = 0; i < entries.Length; i++) + entries[i] = new SlotHashEntry(reader.ReadUInt64(), reader.ReadHash()); + reader.EnsureEnd(); + return new SlotHashesSysvarState(entries); + } +} + +/// Stake activation totals recorded for one epoch. +/// The effective stake. +/// The stake still activating. +/// The stake still deactivating. +public readonly record struct StakeHistoryEntry(ulong Effective, ulong Activating, ulong Deactivating); + +/// An epoch and its stake activation totals. +/// The epoch. +/// The stake totals. +public readonly record struct StakeHistoryEpoch(ulong Epoch, StakeHistoryEntry Entry); + +/// The bounded bincode state of the stake-history sysvar. +public sealed record StakeHistorySysvarState +{ + /// The maximum number of entries retained by the runtime. + public const int MaximumEntries = 512; + + /// The maximum serialized account-data length. + public const int MaximumDataLength = 16_392; + + private StakeHistorySysvarState(IReadOnlyList entries) + { + Entries = entries; + } + + /// The epochs and stake totals in their serialized order. + public IReadOnlyList Entries { get; } + + /// Decodes a bounded bincode stake-history account. + /// The complete account data. + /// The decoded stake history. + /// The data is malformed, truncated, over the runtime limit, or has trailing bytes. + public static StakeHistorySysvarState Parse(ReadOnlySpan data) + { + var reader = new SysvarBincodeReader(data); + var count = reader.ReadBoundedCount(MaximumEntries, "Stake history"); + var entries = new StakeHistoryEpoch[count]; + for (var i = 0; i < entries.Length; i++) + { + var epoch = reader.ReadUInt64(); + var entry = new StakeHistoryEntry(reader.ReadUInt64(), reader.ReadUInt64(), reader.ReadUInt64()); + entries[i] = new StakeHistoryEpoch(epoch, entry); + } + + reader.EnsureEnd(); + return new StakeHistorySysvarState(entries); + } +} diff --git a/src/SolSharp.Core/SysvarStates/FixedSysvarStates.cs b/src/SolSharp.Core/SysvarStates/FixedSysvarStates.cs new file mode 100644 index 0000000..20f5e02 --- /dev/null +++ b/src/SolSharp.Core/SysvarStates/FixedSysvarStates.cs @@ -0,0 +1,156 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Core.SysvarStates; + +/// The bincode state of the clock sysvar. +/// The current slot. +/// The Unix timestamp of the epoch's first slot. +/// The current epoch. +/// The latest epoch with a calculated leader schedule. +/// The approximate Unix timestamp of the current slot. +public readonly record struct ClockSysvarState( + ulong Slot, + long EpochStartTimestamp, + ulong Epoch, + ulong LeaderScheduleEpoch, + long UnixTimestamp) +{ + /// The exact serialized account-data length. + public const int DataLength = 40; + + /// Decodes the exact bincode representation of a clock sysvar account. + /// The complete account data. + /// The decoded clock. + /// The data is truncated or has trailing bytes. + public static ClockSysvarState Parse(ReadOnlySpan data) + { + var reader = new SysvarBincodeReader(data); + var state = new ClockSysvarState( + reader.ReadUInt64(), + reader.ReadInt64(), + reader.ReadUInt64(), + reader.ReadUInt64(), + reader.ReadInt64()); + reader.EnsureEnd(); + return state; + } +} + +/// The current wire fields of the rent sysvar, including the retained deprecated fields. +/// The rent-exemption rate in lamports per account byte. +/// The threshold encoded by the retained little-endian IEEE-754 field. +/// The retained burn percentage field. +public readonly record struct RentSysvarState( + ulong LamportsPerByte, + double ExemptionThreshold, + byte BurnPercent) +{ + /// The exact serialized account-data length. + public const int DataLength = 17; + + /// Decodes the exact bincode representation of a rent sysvar account. + /// The complete account data. + /// The decoded rent configuration. + /// The data is truncated or has trailing bytes. + public static RentSysvarState Parse(ReadOnlySpan data) + { + var reader = new SysvarBincodeReader(data); + var state = new RentSysvarState(reader.ReadUInt64(), reader.ReadDouble(), reader.ReadByte()); + reader.EnsureEnd(); + return state; + } +} + +/// The bincode state of the epoch-schedule sysvar. +/// The maximum slots in an epoch. +/// The leader-schedule calculation offset. +/// Whether epoch lengths grow during warmup. +/// The first epoch after warmup. +/// The first slot after warmup. +public readonly record struct EpochScheduleSysvarState( + ulong SlotsPerEpoch, + ulong LeaderScheduleSlotOffset, + bool Warmup, + ulong FirstNormalEpoch, + ulong FirstNormalSlot) +{ + /// The exact serialized account-data length. + public const int DataLength = 33; + + /// Decodes the exact bincode representation of an epoch-schedule sysvar account. + /// The complete account data. + /// The decoded epoch schedule. + /// The data is malformed, truncated, or has trailing bytes. + public static EpochScheduleSysvarState Parse(ReadOnlySpan data) + { + var reader = new SysvarBincodeReader(data); + var state = new EpochScheduleSysvarState( + reader.ReadUInt64(), + reader.ReadUInt64(), + reader.ReadBool(), + reader.ReadUInt64(), + reader.ReadUInt64()); + reader.EnsureEnd(); + return state; + } +} + +/// The bincode state of the epoch-rewards sysvar. +/// The first block height of the distribution. +/// The number of distribution partitions. +/// The parent hash used to seed partitioning. +/// The total calculated reward points. +/// The total epoch rewards in lamports. +/// The rewards distributed so far in lamports. +/// Whether reward calculation or distribution is active. +public readonly record struct EpochRewardsSysvarState( + ulong DistributionStartingBlockHeight, + ulong NumberOfPartitions, + Hash ParentBlockhash, + UInt128 TotalPoints, + ulong TotalRewards, + ulong DistributedRewards, + bool Active) +{ + /// The exact serialized account-data length. + public const int DataLength = 81; + + /// Decodes the exact bincode representation of an epoch-rewards sysvar account. + /// The complete account data. + /// The decoded epoch rewards. + /// The data is malformed, truncated, or has trailing bytes. + public static EpochRewardsSysvarState Parse(ReadOnlySpan data) + { + var reader = new SysvarBincodeReader(data); + var state = new EpochRewardsSysvarState( + reader.ReadUInt64(), + reader.ReadUInt64(), + reader.ReadHash(), + reader.ReadUInt128(), + reader.ReadUInt64(), + reader.ReadUInt64(), + reader.ReadBool()); + reader.EnsureEnd(); + return state; + } +} + +/// The bincode state of the last-restart-slot sysvar. +/// The last hard-fork restart slot. +public readonly record struct LastRestartSlotSysvarState(ulong LastRestartSlot) +{ + /// The exact serialized account-data length. + public const int DataLength = 8; + + /// Decodes the exact bincode representation of a last-restart-slot sysvar account. + /// The complete account data. + /// The decoded restart slot. + /// The data is truncated or has trailing bytes. + public static LastRestartSlotSysvarState Parse(ReadOnlySpan data) + { + var reader = new SysvarBincodeReader(data); + var state = new LastRestartSlotSysvarState(reader.ReadUInt64()); + reader.EnsureEnd(); + return state; + } +} diff --git a/src/SolSharp.Core/SysvarStates/SlotHistorySysvarState.cs b/src/SolSharp.Core/SysvarStates/SlotHistorySysvarState.cs new file mode 100644 index 0000000..7ca887a --- /dev/null +++ b/src/SolSharp.Core/SysvarStates/SlotHistorySysvarState.cs @@ -0,0 +1,96 @@ +namespace SolSharp.Core.SysvarStates; + +/// The result of checking a slot against the bounded slot-history window. +public enum SlotHistoryCheck +{ + /// The slot is newer than the history's newest slot. + Future, + + /// The slot predates the retained history window. + TooOld, + + /// The slot is marked present. + Found, + + /// The slot is inside the retained window but is not marked present. + NotFound +} + +/// The exact fixed-size wincode state of the slot-history sysvar. +public sealed record SlotHistorySysvarState +{ + private const int BitsPerBlock = 64; + private readonly ulong[] _blocks; + + /// The fixed number of slot bits retained by the runtime. + public const ulong MaximumEntries = 1_048_576; + + /// The exact serialized account-data length. + public const int DataLength = 131_097; + + /// The exact number of serialized 64-bit bit-vector blocks. + public const int BlockCount = 16_384; + + private SlotHistorySysvarState(ulong[] blocks, ulong nextSlot) + { + _blocks = blocks; + NextSlot = nextSlot; + } + + /// The slot immediately after the newest recorded slot. + public ulong NextSlot { get; } + + /// The oldest slot that can still be represented by the history window. + public ulong OldestSlot => NextSlot >= MaximumEntries ? NextSlot - MaximumEntries : 0; + + /// The newest slot represented by the history. + public ulong NewestSlot => unchecked(NextSlot - 1); + + /// Decodes the pinned canonical wincode representation of a slot-history account. + /// The complete 131,097-byte account data. + /// The decoded fixed-size slot history. + /// + /// The data length, bit-vector option tag, block count, or declared bit length is not canonical. + /// + public static SlotHistorySysvarState Parse(ReadOnlySpan data) + { + if (data.Length != DataLength) + throw new ArgumentException($"Slot history data must be exactly {DataLength} bytes.", nameof(data)); + + var reader = new SysvarBincodeReader(data); + if (reader.ReadByte() is not 1) + throw new ArgumentException("Slot history must contain the fixed bit-vector block allocation.", nameof(data)); + + var blockCount = reader.ReadUInt64(); + if (blockCount != BlockCount) + throw new ArgumentException($"Slot history must contain exactly {BlockCount} blocks.", nameof(data)); + + var blocks = new ulong[BlockCount]; + for (var i = 0; i < blocks.Length; i++) + blocks[i] = reader.ReadUInt64(); + + var bitLength = reader.ReadUInt64(); + if (bitLength != MaximumEntries) + throw new ArgumentException($"Slot history must declare exactly {MaximumEntries} bits.", nameof(data)); + + var state = new SlotHistorySysvarState(blocks, reader.ReadUInt64()); + reader.EnsureEnd(); + return state; + } + + /// Checks a slot using the pinned runtime's future, age-window, and bit-presence ordering. + /// The slot to check. + /// The slot's relationship to the retained history. + public SlotHistoryCheck Check(ulong slot) + { + if (slot > NewestSlot) + return SlotHistoryCheck.Future; + if (slot < OldestSlot) + return SlotHistoryCheck.TooOld; + + var bitIndex = slot % MaximumEntries; + var block = _blocks[(int)(bitIndex / BitsPerBlock)]; + var mask = 1UL << (int)(bitIndex % BitsPerBlock); + return (block & mask) is not 0 ? SlotHistoryCheck.Found : SlotHistoryCheck.NotFound; + } +} diff --git a/src/SolSharp.Core/SysvarStates/SysvarBincodeReader.cs b/src/SolSharp.Core/SysvarStates/SysvarBincodeReader.cs new file mode 100644 index 0000000..a19991e --- /dev/null +++ b/src/SolSharp.Core/SysvarStates/SysvarBincodeReader.cs @@ -0,0 +1,60 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Core.SysvarStates; + +internal ref struct SysvarBincodeReader(ReadOnlySpan data) +{ + private readonly ReadOnlySpan _data = data; + private int _offset; + + public bool ReadBool() + { + var value = ReadByte(); + return value switch + { + 0 => false, + 1 => true, + _ => throw Invalid("Boolean values must use the canonical 0 or 1 encoding.") + }; + } + + public byte ReadByte() => ReadBytes(sizeof(byte))[0]; + + public ulong ReadUInt64() => BinaryPrimitives.ReadUInt64LittleEndian(ReadBytes(sizeof(ulong))); + + public long ReadInt64() => BinaryPrimitives.ReadInt64LittleEndian(ReadBytes(sizeof(long))); + + public UInt128 ReadUInt128() => BinaryPrimitives.ReadUInt128LittleEndian(ReadBytes(16)); + + public double ReadDouble() => BitConverter.Int64BitsToDouble(ReadInt64()); + + public Hash ReadHash() => new(ReadBytes(Hash.Length)); + + public int ReadBoundedCount(int maximum, string collectionName) + { + var count = ReadUInt64(); + if (count > (ulong)maximum) + throw Invalid($"{collectionName} count {count} exceeds the maximum of {maximum}."); + + return (int)count; + } + + public readonly void EnsureEnd() + { + if (_offset != _data.Length) + throw Invalid($"Account data has {_data.Length - _offset} trailing bytes."); + } + + private ReadOnlySpan ReadBytes(int length) + { + if (length > _data.Length - _offset) + throw Invalid("Account data is truncated."); + + var result = _data.Slice(_offset, length); + _offset += length; + return result; + } + + private static ArgumentException Invalid(string message) => new(message); +} diff --git a/src/SolSharp.Programs/AddressLookupTableAccount.cs b/src/SolSharp.Programs/AddressLookupTableAccount.cs index 9f90035..f7d2d71 100644 --- a/src/SolSharp.Programs/AddressLookupTableAccount.cs +++ b/src/SolSharp.Programs/AddressLookupTableAccount.cs @@ -4,7 +4,7 @@ namespace SolSharp.Programs; /// /// An on-chain Address Lookup Table as a v0 message consumes it: the table account's address and the -/// ordered addresses it stores. moves referenced accounts found here out +/// ordered addresses it stores. The compiler moves referenced accounts found here out /// of the static keys and into a table lookup. /// /// The lookup table account's address. diff --git a/src/SolSharp.Programs/AddressLookupTableProgram.cs b/src/SolSharp.Programs/AddressLookupTableProgram.cs index e920329..3c3643c 100644 --- a/src/SolSharp.Programs/AddressLookupTableProgram.cs +++ b/src/SolSharp.Programs/AddressLookupTableProgram.cs @@ -13,10 +13,10 @@ public static class AddressLookupTableProgram public static PublicKey ProgramId { get; } = PublicKey.Parse("AddressLookupTab1e1111111111111111111111111"); /// - /// Creates a new lookup table owned by . The table's address is a PDA derived + /// Creates a new lookup table controlled by . The table's address is a PDA derived /// from the authority and , which must be a recent slot the node has seen. /// - /// The account that will control the table (signer). + /// The account that will control the table. /// The account that funds the new table account (writable signer). /// A recent slot; the table address is derived from it, so it must be current. /// The create instruction and the derived lookup table address. @@ -41,7 +41,7 @@ public static (Instruction Instruction, PublicKey LookupTable) CreateLookupTable Accounts = [ AccountMeta.Writable(lookupTable), - AccountMeta.ReadonlySigner(authority), + AccountMeta.Readonly(authority), AccountMeta.WritableSigner(payer), AccountMeta.Readonly(SystemProgram.ProgramId) ], @@ -66,7 +66,7 @@ public static Instruction ExtendLookupTable( { ArgumentNullException.ThrowIfNull(newAddresses); - using var buffer = new MemoryStream(sizeof(uint) + sizeof(ulong) + newAddresses.Count * PublicKey.Length); + using var buffer = new MemoryStream(sizeof(uint) + sizeof(ulong) + (newAddresses.Count * PublicKey.Length)); Span head = stackalloc byte[sizeof(uint) + sizeof(ulong)]; BinaryPrimitives.WriteUInt32LittleEndian(head, 2); BinaryPrimitives.WriteUInt64LittleEndian(head[sizeof(uint)..], (ulong)newAddresses.Count); diff --git a/src/SolSharp.Programs/AddressLookupTableState.cs b/src/SolSharp.Programs/AddressLookupTableState.cs new file mode 100644 index 0000000..8cb64ff --- /dev/null +++ b/src/SolSharp.Programs/AddressLookupTableState.cs @@ -0,0 +1,281 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; +using SolSharp.Core.SysvarStates; + +namespace SolSharp.Programs; + +/// The serialized Address Lookup Table program state variant. +public enum AddressLookupTableStateKind : uint +{ + /// The account is uninitialized. + Uninitialized = 0, + + /// The account contains lookup-table metadata and addresses. + LookupTable = 1 +} + +/// The runtime activation state of a lookup table at a particular slot. +public enum AddressLookupTableStatusKind +{ + /// The table has not begun deactivation. + Activated, + + /// The table is cooling down and remains usable. + Deactivating, + + /// The deactivation slot is no longer retained in SlotHashes. + Deactivated +} + +/// A lookup table's activation state and remaining cooldown blocks. +/// The activation-state branch. +/// +/// The conservative remaining cooldown count for ; +/// zero for the other branches. +/// +public readonly record struct AddressLookupTableStatus( + AddressLookupTableStatusKind Kind, + int RemainingBlocks = 0); + +/// Decoded Address Lookup Table metadata and stored addresses. +public sealed class AddressLookupTableState +{ + private readonly PublicKey[] _addresses; + + private AddressLookupTableState( + AddressLookupTableStateKind kind, + ulong deactivationSlot, + ulong lastExtendedSlot, + byte lastExtendedSlotStartIndex, + PublicKey? authority, + PublicKey[] addresses) + { + Kind = kind; + DeactivationSlot = deactivationSlot; + LastExtendedSlot = lastExtendedSlot; + LastExtendedSlotStartIndex = lastExtendedSlotStartIndex; + Authority = authority; + _addresses = addresses; + Addresses = Array.AsReadOnly(addresses); + } + + /// The fixed metadata length before lookup addresses. + public const int MetadataLength = 56; + + /// The maximum number of addresses in one table. + public const int MaximumAddresses = 256; + + /// The decoded state variant. + public AddressLookupTableStateKind Kind { get; } + + /// The deactivation slot, or while activated. + public ulong DeactivationSlot { get; } + + /// The slot in which the table was most recently extended. + public ulong LastExtendedSlot { get; } + + /// The address index at which the most recent extension began. + public byte LastExtendedSlotStartIndex { get; } + + /// The table authority, or null when the table is frozen or uninitialized. + public PublicKey? Authority { get; } + + /// The stored lookup addresses. + public IReadOnlyList Addresses { get; } + + /// + /// Estimates the last valid slot from the pinned SDK's 512-entry SlotHashes window. A current slot + /// below the estimate is guaranteed to remain in cooldown; skipped blocks may keep the table usable + /// at or beyond the estimate. + /// + /// The slot at which deactivation began. + /// The saturating deactivation slot plus the SlotHashes capacity. + public static ulong EstimateLastValidSlot(ulong deactivationSlot) + => deactivationSlot > ulong.MaxValue - SlotHashesSysvarState.MaximumEntries + ? ulong.MaxValue + : deactivationSlot + SlotHashesSysvarState.MaximumEntries; + + /// Computes the table's exact runtime activation state from the current SlotHashes state. + /// The bank slot performing the lookup. + /// The current SlotHashes sysvar state. + /// The activated, cooling-down, or deactivated status. + /// is null. + /// This state is uninitialized. + public AddressLookupTableStatus GetStatus(ulong currentSlot, SlotHashesSysvarState slotHashes) + { + ArgumentNullException.ThrowIfNull(slotHashes); + EnsureInitialized(); + + if (DeactivationSlot == ulong.MaxValue) + return new AddressLookupTableStatus(AddressLookupTableStatusKind.Activated); + if (DeactivationSlot == currentSlot) + { + return new AddressLookupTableStatus( + AddressLookupTableStatusKind.Deactivating, + SlotHashesSysvarState.MaximumEntries + 1); + } + + for (var i = 0; i < slotHashes.Entries.Count; i++) + { + if (slotHashes.Entries[i].Slot == DeactivationSlot) + { + return new AddressLookupTableStatus( + AddressLookupTableStatusKind.Deactivating, + SlotHashesSysvarState.MaximumEntries - i); + } + } + + return new AddressLookupTableStatus(AddressLookupTableStatusKind.Deactivated); + } + + /// Returns whether the table remains usable for lookups at the supplied slot. + /// The bank slot performing the lookup. + /// The current SlotHashes sysvar state. + /// true for activated and cooling-down tables; otherwise false. + /// is null. + /// This state is uninitialized. + public bool IsActive(ulong currentSlot, SlotHashesSysvarState slotHashes) + => GetStatus(currentSlot, slotHashes).Kind is not AddressLookupTableStatusKind.Deactivated; + + /// Gets the number of addresses visible to a lookup in the supplied bank slot. + /// The bank slot performing the lookup. + /// The current SlotHashes sysvar state. + /// The full stored count, or the pre-extension prefix for a same-slot lookup. + /// is null. + /// The table is uninitialized or fully deactivated. + public int GetActiveAddressesLength(ulong currentSlot, SlotHashesSysvarState slotHashes) + { + if (!IsActive(currentSlot, slotHashes)) + throw new InvalidOperationException("A deactivated lookup table is no longer available for address lookups."); + + return currentSlot > LastExtendedSlot ? _addresses.Length : LastExtendedSlotStartIndex; + } + + /// Returns defensive copies of the addresses visible to a lookup in the supplied bank slot. + /// The bank slot performing the lookup. + /// The current SlotHashes sysvar state. + /// The active address prefix. + /// is null. + /// The table is uninitialized or fully deactivated. + /// The stored same-slot start index exceeds the address count. + public PublicKey[] GetActiveAddresses(ulong currentSlot, SlotHashesSysvarState slotHashes) + { + var count = GetActiveAddressesLength(currentSlot, slotHashes); + if (count > _addresses.Length) + throw new FormatException("The lookup table's active-address prefix exceeds its stored address count."); + + return [.. _addresses.AsSpan(0, count)]; + } + + /// Resolves lookup indexes against the active address prefix for the supplied bank slot. + /// The bank slot performing the lookup. + /// The ordered one-byte address indexes. + /// The current SlotHashes sysvar state. + /// The resolved addresses in caller order. + /// + /// or is null. + /// + /// The table is uninitialized or fully deactivated. + /// The active prefix or an index is invalid. + public PublicKey[] Lookup( + ulong currentSlot, + IReadOnlyList indexes, + SlotHashesSysvarState slotHashes) + { + ArgumentNullException.ThrowIfNull(indexes); + var active = GetActiveAddresses(currentSlot, slotHashes); + var resolved = new PublicKey[indexes.Count]; + for (var i = 0; i < indexes.Count; i++) + { + var index = indexes[i]; + if (index >= active.Length) + throw new FormatException($"Lookup-table address index {index} is outside the active prefix."); + resolved[i] = active[index]; + } + + return resolved; + } + + /// Decodes complete Address Lookup Table account data. + /// The account data. + /// The decoded state. + /// The data is truncated, misaligned, or contains too many addresses. + /// The state discriminator or authority option tag is invalid. + public static AddressLookupTableState Parse(ReadOnlySpan data) + { + if (data.Length < sizeof(uint)) + throw new ArgumentException("Lookup-table state requires a four-byte discriminator.", nameof(data)); + + var discriminator = BinaryPrimitives.ReadUInt32LittleEndian(data); + if (discriminator == (uint)AddressLookupTableStateKind.Uninitialized) + { + return new AddressLookupTableState( + AddressLookupTableStateKind.Uninitialized, + ulong.MaxValue, + 0, + 0, + null, + []); + } + + if (discriminator != (uint)AddressLookupTableStateKind.LookupTable) + throw new FormatException($"Unknown lookup-table state discriminator {discriminator}."); + if (data.Length < MetadataLength) + throw new ArgumentException($"Initialized lookup-table data requires at least {MetadataLength} bytes.", nameof(data)); + + var addressBytes = data[MetadataLength..]; + if (addressBytes.Length % PublicKey.Length is not 0) + throw new ArgumentException("Lookup-table address data must be a multiple of 32 bytes.", nameof(data)); + + var count = addressBytes.Length / PublicKey.Length; + if (count > MaximumAddresses) + throw new ArgumentException($"A lookup table may contain at most {MaximumAddresses} addresses.", nameof(data)); + + var authority = data[21] switch + { + 0 => (PublicKey?)null, + 1 => new PublicKey(data.Slice(22, PublicKey.Length)), + var tag => throw new FormatException($"Invalid lookup-table authority option tag {tag}.") + }; + var addresses = new PublicKey[count]; + for (var i = 0; i < addresses.Length; i++) + addresses[i] = new PublicKey(addressBytes.Slice(i * PublicKey.Length, PublicKey.Length)); + + return new AddressLookupTableState( + AddressLookupTableStateKind.LookupTable, + BinaryPrimitives.ReadUInt64LittleEndian(data[4..]), + BinaryPrimitives.ReadUInt64LittleEndian(data[12..]), + data[20], + authority, + addresses); + } + + /// Attempts to decode Address Lookup Table account data. + /// The account data. + /// The decoded state on success; otherwise null. + /// true when the input is a valid state. + public static bool TryParse(ReadOnlySpan data, out AddressLookupTableState? state) + { + try + { + state = Parse(data); + return true; + } + catch (ArgumentException) + { + state = null; + return false; + } + catch (FormatException) + { + state = null; + return false; + } + } + + private void EnsureInitialized() + { + if (Kind is not AddressLookupTableStateKind.LookupTable) + throw new InvalidOperationException("An uninitialized lookup-table state has no activation status."); + } +} diff --git a/src/SolSharp.Programs/AssociatedTokenAccount.cs b/src/SolSharp.Programs/AssociatedTokenAccount.cs index c396ee7..3637b71 100644 --- a/src/SolSharp.Programs/AssociatedTokenAccount.cs +++ b/src/SolSharp.Programs/AssociatedTokenAccount.cs @@ -11,6 +11,20 @@ public static class AssociatedTokenAccount private static readonly PublicKey DefaultTokenProgram = PublicKey.Parse(SolanaProgramIds.TokenProgram); + /// Decodes an ATA instruction tag. + /// Complete ATA instruction data. + /// Create, CreateIdempotent, or RecoverNested; otherwise null. + public static string? DecodeInstructionData(ReadOnlySpan data) + => data.Length == 1 + ? data[0] switch + { + 0 => "Create", + 1 => "CreateIdempotent", + 2 => "RecoverNested", + _ => null + } + : null; + /// Derives the associated token account address holding for . /// The wallet that owns the token account. /// The token mint. @@ -30,7 +44,7 @@ public static PublicKey GetAddress(PublicKey owner, PublicKey mint, PublicKey? t /// The token program; SPL Token by default, or pass Token-2022 for its mints. /// The create-account instruction. public static Instruction Create(PublicKey payer, PublicKey owner, PublicKey mint, PublicKey? tokenProgram = null) - => Build(payer, owner, mint, tokenProgram, data: []); + => Build(payer, owner, mint, tokenProgram, data: [0]); /// /// Builds the idempotent create instruction: like , but succeeds as a no-op when the @@ -44,7 +58,44 @@ public static Instruction Create(PublicKey payer, PublicKey owner, PublicKey min public static Instruction CreateIdempotent(PublicKey payer, PublicKey owner, PublicKey mint, PublicKey? tokenProgram = null) => Build(payer, owner, mint, tokenProgram, data: [1]); - // Create and CreateIdempotent differ only in the instruction tag: empty data is Create, [1] is CreateIdempotent. + /// + /// Recovers tokens and lamports from an accidentally nested associated token account into the wallet's + /// canonical associated token account. + /// + /// The wallet that owns the outer associated token account; writable and signs. + /// The mint of the outer associated token account. + /// The mint held by the nested associated token account. + /// The token program; SPL Token by default, or pass Token-2022 for its mints. + /// The recover-nested instruction. + public static Instruction RecoverNested( + PublicKey wallet, + PublicKey ownerMint, + PublicKey nestedMint, + PublicKey? tokenProgram = null) + { + var program = tokenProgram ?? DefaultTokenProgram; + var ownerAssociatedAccount = GetAddress(wallet, ownerMint, program); + var destinationAssociatedAccount = GetAddress(wallet, nestedMint, program); + var nestedAssociatedAccount = GetAddress(ownerAssociatedAccount, nestedMint, program); + + return new Instruction + { + ProgramId = ProgramId, + Accounts = + [ + AccountMeta.Writable(nestedAssociatedAccount), + AccountMeta.Readonly(nestedMint), + AccountMeta.Writable(destinationAssociatedAccount), + AccountMeta.Readonly(ownerAssociatedAccount), + AccountMeta.Readonly(ownerMint), + AccountMeta.WritableSigner(wallet), + AccountMeta.Readonly(program) + ], + Data = [2] + }; + } + + // Create and CreateIdempotent differ only in the instruction tag: [0] is Create, [1] is CreateIdempotent. private static Instruction Build(PublicKey payer, PublicKey owner, PublicKey mint, PublicKey? tokenProgram, byte[] data) { var program = tokenProgram ?? DefaultTokenProgram; diff --git a/src/SolSharp.Programs/AuthorityType.cs b/src/SolSharp.Programs/AuthorityType.cs index e561bb0..76805f4 100644 --- a/src/SolSharp.Programs/AuthorityType.cs +++ b/src/SolSharp.Programs/AuthorityType.cs @@ -1,7 +1,10 @@ +using SolSharp.Core.Primitives; + namespace SolSharp.Programs; /// -/// Which authority of a mint or token account changes. The first +/// Which authority of a mint or token account +/// changes. The first /// four variants are the classic SPL Token set; the rest are Token-2022 extension authorities, valid only /// when the instruction targets the Token-2022 program. /// diff --git a/src/SolSharp.Programs/ConfidentialProofLocation.cs b/src/SolSharp.Programs/ConfidentialProofLocation.cs new file mode 100644 index 0000000..9c2ff75 --- /dev/null +++ b/src/SolSharp.Programs/ConfidentialProofLocation.cs @@ -0,0 +1,50 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// +/// Identifies precomputed zero-knowledge proof data used by a Token-2022 instruction: either a +/// non-zero relative transaction-instruction offset or a pre-verified proof-context account. +/// This type describes only the proof location; it does not generate or validate cryptographic proofs. +/// +public sealed class ConfidentialProofLocation +{ + private ConfidentialProofLocation(sbyte instructionOffset, PublicKey? contextStateAccount) + { + InstructionOffset = instructionOffset; + ContextStateAccount = contextStateAccount; + } + + /// Whether the proof is supplied by another instruction in the same transaction. + public bool IsInstructionOffset => ContextStateAccount is null; + + /// + /// The non-zero relative proof-instruction offset, or zero when + /// is used. + /// + public sbyte InstructionOffset { get; } + + /// The pre-verified proof-context account, or null when an instruction offset is used. + public PublicKey? ContextStateAccount { get; } + + /// Uses a precomputed proof verification instruction at a relative transaction offset. + /// A non-zero signed relative instruction offset. + /// The proof location. + /// is zero. + public static ConfidentialProofLocation AtInstructionOffset(sbyte instructionOffset) + { + if (instructionOffset == 0) + throw new ArgumentOutOfRangeException( + nameof(instructionOffset), + instructionOffset, + "A proof instruction offset must be non-zero; zero denotes a context-state account on the wire."); + + return new ConfidentialProofLocation(instructionOffset, contextStateAccount: null); + } + + /// Uses a proof that was pre-verified into a context-state account. + /// The proof-context account. + /// The proof location. + public static ConfidentialProofLocation AtContextState(PublicKey contextStateAccount) + => new(instructionOffset: 0, contextStateAccount); +} diff --git a/src/SolSharp.Programs/DefaultTokenAccountState.cs b/src/SolSharp.Programs/DefaultTokenAccountState.cs new file mode 100644 index 0000000..4f2275d --- /dev/null +++ b/src/SolSharp.Programs/DefaultTokenAccountState.cs @@ -0,0 +1,14 @@ +namespace SolSharp.Programs; + +/// The default state assigned to new accounts of a Token-2022 mint. +public enum DefaultTokenAccountState : byte +{ + /// The account has not been initialized. + Uninitialized = 0, + + /// The account is initialized and usable. + Initialized = 1, + + /// The account starts frozen and requires its mint's freeze authority to thaw it. + Frozen = 2 +} diff --git a/src/SolSharp.Programs/Ed25519Program.cs b/src/SolSharp.Programs/Ed25519Program.cs new file mode 100644 index 0000000..8c5caf1 --- /dev/null +++ b/src/SolSharp.Programs/Ed25519Program.cs @@ -0,0 +1,135 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Builds and decodes Ed25519 native signature-verification instructions. +public static class Ed25519Program +{ + /// The serialized Ed25519 public-key length. + public const int PublicKeyLength = 32; + + /// The serialized Ed25519 signature length. + public const int SignatureLength = 64; + + /// The serialized length of one offsets record. + public const int SignatureOffsetsLength = 14; + + /// The first byte of self-contained signature data. + public const int DataStart = 16; + + /// The Ed25519 native precompile address. + public static readonly PublicKey ProgramId = + PublicKey.Parse("Ed25519SigVerify111111111111111111111111111"); + + /// Builds a self-contained verification instruction for one precomputed signature. + /// The signed message. + /// The 64-byte Ed25519 signature. + /// The 32-byte Ed25519 public key. + /// The account-free precompile instruction. + public static Instruction CreateInstruction( + ReadOnlySpan message, + ReadOnlySpan signature, + ReadOnlySpan publicKey) + { + ValidateLength(signature, SignatureLength, nameof(signature)); + ValidateLength(publicKey, PublicKeyLength, nameof(publicKey)); + ValidateMessageLength(message); + + const ushort publicKeyOffset = DataStart; + const ushort signatureOffset = DataStart + PublicKeyLength; + const ushort messageOffset = DataStart + PublicKeyLength + SignatureLength; + var data = new byte[messageOffset + message.Length]; + BinaryPrimitives.WriteUInt16LittleEndian(data, 1); + WriteOffsets( + data.AsSpan(2), + new Ed25519SignatureOffsets( + signatureOffset, + ushort.MaxValue, + publicKeyOffset, + ushort.MaxValue, + messageOffset, + (ushort)message.Length, + ushort.MaxValue)); + publicKey.CopyTo(data.AsSpan(publicKeyOffset, PublicKeyLength)); + signature.CopyTo(data.AsSpan(signatureOffset, SignatureLength)); + message.CopyTo(data.AsSpan(messageOffset)); + return new Instruction { ProgramId = ProgramId, Accounts = [], Data = data }; + } + + /// Builds an offsets-only instruction for data stored in this or other instructions. + /// The offsets records. + /// The account-free precompile instruction. + /// More than 255 records are supplied. + public static Instruction CreateOffsetsInstruction(IReadOnlyList offsets) + { + ArgumentNullException.ThrowIfNull(offsets); + if (offsets.Count > byte.MaxValue) + throw new ArgumentException("The Ed25519 precompile accepts at most 255 offset records.", nameof(offsets)); + + var data = new byte[checked(2 + (offsets.Count * SignatureOffsetsLength))]; + data[0] = (byte)offsets.Count; + for (var i = 0; i < offsets.Count; i++) + WriteOffsets(data.AsSpan(2 + (i * SignatureOffsetsLength)), offsets[i]); + return new Instruction { ProgramId = ProgramId, Accounts = [], Data = data }; + } + + /// Decodes the offsets table at the start of Ed25519 instruction data. + /// The complete instruction data. + /// The decoded records; appended signature data is ignored. + /// The header or offsets table is truncated. + public static Ed25519SignatureOffsets[] DecodeOffsets(ReadOnlySpan data) + { + if (data.Length < 2) + throw new ArgumentException("Ed25519 instruction data requires a two-byte count.", nameof(data)); + + var count = data[0]; + if (count == 0 && data.Length > 2) + throw new ArgumentException("A zero-count Ed25519 instruction cannot contain trailing data.", nameof(data)); + var tableLength = checked(2 + (count * SignatureOffsetsLength)); + if (data.Length < tableLength) + throw new ArgumentException("Ed25519 instruction data contains a truncated offsets table.", nameof(data)); + + var offsets = new Ed25519SignatureOffsets[count]; + for (var i = 0; i < offsets.Length; i++) + { + var record = data[(2 + (i * SignatureOffsetsLength))..]; + offsets[i] = new Ed25519SignatureOffsets( + ReadUInt16(record, 0), + ReadUInt16(record, 2), + ReadUInt16(record, 4), + ReadUInt16(record, 6), + ReadUInt16(record, 8), + ReadUInt16(record, 10), + ReadUInt16(record, 12)); + } + + return offsets; + } + + private static void WriteOffsets(Span destination, Ed25519SignatureOffsets offsets) + { + BinaryPrimitives.WriteUInt16LittleEndian(destination, offsets.SignatureOffset); + BinaryPrimitives.WriteUInt16LittleEndian(destination[2..], offsets.SignatureInstructionIndex); + BinaryPrimitives.WriteUInt16LittleEndian(destination[4..], offsets.PublicKeyOffset); + BinaryPrimitives.WriteUInt16LittleEndian(destination[6..], offsets.PublicKeyInstructionIndex); + BinaryPrimitives.WriteUInt16LittleEndian(destination[8..], offsets.MessageOffset); + BinaryPrimitives.WriteUInt16LittleEndian(destination[10..], offsets.MessageLength); + BinaryPrimitives.WriteUInt16LittleEndian(destination[12..], offsets.MessageInstructionIndex); + } + + private static ushort ReadUInt16(ReadOnlySpan data, int offset) + => BinaryPrimitives.ReadUInt16LittleEndian(data[offset..]); + + private static void ValidateLength(ReadOnlySpan value, int expected, string parameterName) + { + if (value.Length != expected) + throw new ArgumentException($"Value must be exactly {expected} bytes, got {value.Length}.", parameterName); + } + + private static void ValidateMessageLength(ReadOnlySpan message) + { + if (message.Length > ushort.MaxValue) + throw new ArgumentException("A precompile message may contain at most 65,535 bytes.", nameof(message)); + } +} diff --git a/src/SolSharp.Programs/ElGamalProofProgram.cs b/src/SolSharp.Programs/ElGamalProofProgram.cs new file mode 100644 index 0000000..0b6826f --- /dev/null +++ b/src/SolSharp.Programs/ElGamalProofProgram.cs @@ -0,0 +1,196 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// The instruction discriminators of the native ZK ElGamal proof program. +public enum ElGamalProofInstruction : byte +{ + /// Closes a proof-context state account. + CloseContextState = 0, + + /// Verifies a zero-ciphertext proof. + VerifyZeroCiphertext = 1, + + /// Verifies equality between two ElGamal ciphertexts. + VerifyCiphertextCiphertextEquality = 2, + + /// Verifies equality between an ElGamal ciphertext and a Pedersen commitment. + VerifyCiphertextCommitmentEquality = 3, + + /// Verifies knowledge of the secret key for an ElGamal public key. + VerifyPubkeyValidity = 4, + + /// Verifies a percentage-with-cap relation. + VerifyPercentageWithCap = 5, + + /// Verifies a batched 64-bit range proof. + VerifyBatchedRangeProofU64 = 6, + + /// Verifies a batched 128-bit range proof. + VerifyBatchedRangeProofU128 = 7, + + /// Verifies a batched 256-bit range proof. + VerifyBatchedRangeProofU256 = 8, + + /// Verifies a grouped ciphertext with two decryption handles. + VerifyGroupedCiphertext2HandlesValidity = 9, + + /// Verifies two grouped ciphertexts with two decryption handles. + VerifyBatchedGroupedCiphertext2HandlesValidity = 10, + + /// Verifies a grouped ciphertext with three decryption handles. + VerifyGroupedCiphertext3HandlesValidity = 11, + + /// Verifies two grouped ciphertexts with three decryption handles. + VerifyBatchedGroupedCiphertext3HandlesValidity = 12 +} + +/// +/// Builds native ZK ElGamal proof-program instructions from already-generated proof POD bytes. +/// Proof creation is intentionally outside this API; the supplied bytes must come from a compatible +/// cryptographic implementation. +/// +public static class ElGamalProofProgram +{ + /// The native ZK ElGamal proof program address. + public static readonly PublicKey ProgramId = PublicKey.Parse("ZkE1Gama1Proof11111111111111111111111111111"); + + /// Closes a proof-context account and sends its lamports to a destination. + /// The writable proof-context account. + /// The writable lamport destination. + /// The context owner; signs. + /// The close-context-state instruction. + public static Instruction CloseContextState( + PublicKey contextStateAccount, + PublicKey destination, + PublicKey contextStateAuthority) + => new() + { + ProgramId = ProgramId, + Accounts = + [ + AccountMeta.Writable(contextStateAccount), + AccountMeta.Writable(destination), + AccountMeta.ReadonlySigner(contextStateAuthority) + ], + Data = [(byte)ElGamalProofInstruction.CloseContextState] + }; + + /// Builds a verification instruction with the complete precomputed proof POD in instruction data. + /// The proof verifier to invoke. + /// The complete upstream proof-data POD, excluding the one-byte discriminator. + /// An optional writable account in which to store the verified context. + /// The context owner, required exactly when a context account is supplied. + /// The proof verification instruction. + /// is not a verifier. + /// Only one of the two context arguments is supplied. + public static Instruction VerifyProof( + ElGamalProofInstruction proofInstruction, + ReadOnlySpan proofData, + PublicKey? contextStateAccount = null, + PublicKey? contextStateAuthority = null) + { + ValidateProofInstruction(proofInstruction); + var expectedLength = GetProofDataLength(proofInstruction); + if (proofData.Length != expectedLength) + throw new ArgumentException( + $"The {proofInstruction} proof-data POD must contain exactly {expectedLength} bytes, got {proofData.Length}.", + nameof(proofData)); + var data = new byte[proofData.Length + 1]; + data[0] = (byte)proofInstruction; + proofData.CopyTo(data.AsSpan(1)); + return new Instruction + { + ProgramId = ProgramId, + Accounts = ContextAccounts(contextStateAccount, contextStateAuthority), + Data = data + }; + } + + /// Builds a verifier that reads a precomputed proof POD from an account at a byte offset. + /// The proof verifier to invoke. + /// The readonly account containing proof bytes. + /// The byte offset of the proof POD within the account. + /// An optional writable account in which to store the verified context. + /// The context owner, required exactly when a context account is supplied. + /// The proof verification instruction. + /// is not a verifier. + /// Only one of the two context arguments is supplied. + public static Instruction VerifyProofFromAccount( + ElGamalProofInstruction proofInstruction, + PublicKey proofAccount, + uint proofDataOffset, + PublicKey? contextStateAccount = null, + PublicKey? contextStateAuthority = null) + { + ValidateProofInstruction(proofInstruction); + var accounts = new List { AccountMeta.Readonly(proofAccount) }; + accounts.AddRange(ContextAccounts(contextStateAccount, contextStateAuthority)); + var data = new byte[1 + sizeof(uint)]; + data[0] = (byte)proofInstruction; + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(1), proofDataOffset); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } + + /// Decodes the instruction discriminator from native proof-program data. + /// Instruction data. + /// The decoded instruction on success. + /// true when the first byte is a defined proof-program discriminator. + public static bool TryDecodeInstruction(ReadOnlySpan data, out ElGamalProofInstruction proofInstruction) + { + if (!data.IsEmpty && Enum.IsDefined((ElGamalProofInstruction)data[0])) + { + proofInstruction = (ElGamalProofInstruction)data[0]; + return true; + } + + proofInstruction = default; + return false; + } + + /// Gets the exact upstream proof-data POD length for a verifier. + /// A proof verification discriminator. + /// The required POD byte length, excluding the discriminator. + /// is not a verifier. + public static int GetProofDataLength(ElGamalProofInstruction proofInstruction) + { + ValidateProofInstruction(proofInstruction); + return proofInstruction switch + { + ElGamalProofInstruction.VerifyZeroCiphertext => 192, + ElGamalProofInstruction.VerifyCiphertextCiphertextEquality => 416, + ElGamalProofInstruction.VerifyCiphertextCommitmentEquality => 320, + ElGamalProofInstruction.VerifyPubkeyValidity => 96, + ElGamalProofInstruction.VerifyPercentageWithCap => 360, + ElGamalProofInstruction.VerifyBatchedRangeProofU64 => 936, + ElGamalProofInstruction.VerifyBatchedRangeProofU128 => 1000, + ElGamalProofInstruction.VerifyBatchedRangeProofU256 => 1064, + ElGamalProofInstruction.VerifyGroupedCiphertext2HandlesValidity => 320, + ElGamalProofInstruction.VerifyBatchedGroupedCiphertext2HandlesValidity => 416, + ElGamalProofInstruction.VerifyGroupedCiphertext3HandlesValidity => 416, + ElGamalProofInstruction.VerifyBatchedGroupedCiphertext3HandlesValidity => 544, + _ => throw new ArgumentOutOfRangeException(nameof(proofInstruction), proofInstruction, "Unknown proof verifier.") + }; + } + + private static IReadOnlyList ContextAccounts( + PublicKey? contextStateAccount, + PublicKey? contextStateAuthority) + { + if (contextStateAccount.HasValue != contextStateAuthority.HasValue) + throw new ArgumentException("A proof context account and its authority must be supplied together."); + return contextStateAccount is { } account + ? [AccountMeta.Writable(account), AccountMeta.Readonly(contextStateAuthority!.Value)] + : []; + } + + private static void ValidateProofInstruction(ElGamalProofInstruction proofInstruction) + { + if (!Enum.IsDefined(proofInstruction) || proofInstruction == ElGamalProofInstruction.CloseContextState) + throw new ArgumentOutOfRangeException( + nameof(proofInstruction), + proofInstruction, + "The instruction must be a defined proof verification discriminator."); + } +} diff --git a/src/SolSharp.Programs/ElGamalRegistryProgram.cs b/src/SolSharp.Programs/ElGamalRegistryProgram.cs new file mode 100644 index 0000000..9386f6d --- /dev/null +++ b/src/SolSharp.Programs/ElGamalRegistryProgram.cs @@ -0,0 +1,86 @@ +using System.Text; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Builds SPL ElGamal public-key registry interface instructions. +public static class ElGamalRegistryProgram +{ + /// The encoded ElGamal registry state length. + public const int RegistryStateLength = PublicKey.Length * 2; + + private static readonly byte[] RegistrySeed = Encoding.ASCII.GetBytes("elgamal-registry"); + private static readonly PublicKey InstructionsSysvar = PublicKey.Parse(Sysvars.Instructions); + + /// The SPL ElGamal registry program address. + public static readonly PublicKey ProgramId = PublicKey.Parse("regVYJW7tcT8zipN5YiBvHsvR5jXW1uLFxaHSbugABg"); + + /// Derives the canonical registry PDA for a wallet owner. + /// The wallet owner. + /// The registry PDA. + public static PublicKey GetRegistryAddress(PublicKey owner) + => ProgramDerivedAddress.FindProgramAddress([RegistrySeed, owner.ToBytes()], ProgramId).Address; + + /// Creates a registry whose ElGamal key is certified by a precomputed validity proof. + /// The wallet owner; signs. + /// The proof instruction offset or pre-verified context account. + /// The create-registry instruction. + public static Instruction CreateRegistry(PublicKey owner, ConfidentialProofLocation proofLocation) + { + ArgumentNullException.ThrowIfNull(proofLocation); + var accounts = new List + { + AccountMeta.Writable(GetRegistryAddress(owner)), + AccountMeta.ReadonlySigner(owner), + AccountMeta.Readonly(SystemProgram.ProgramId) + }; + var offset = AppendProofAccount(accounts, proofLocation); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = [0, unchecked((byte)offset)] }; + } + + /// Updates a registry with an ElGamal key certified by a precomputed validity proof. + /// The wallet owner; signs. + /// The proof instruction offset or pre-verified context account. + /// The update-registry instruction. + public static Instruction UpdateRegistry(PublicKey owner, ConfidentialProofLocation proofLocation) + { + ArgumentNullException.ThrowIfNull(proofLocation); + var accounts = new List { AccountMeta.Writable(GetRegistryAddress(owner)) }; + var offset = AppendProofAccount(accounts, proofLocation); + accounts.Add(AccountMeta.ReadonlySigner(owner)); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = [1, unchecked((byte)offset)] }; + } + + /// Decodes the fixed 64-byte registry state. + /// Exactly 64 bytes: owner followed by ElGamal public-key POD bytes. + /// The decoded registry, or null for the wrong length. + public static ElGamalRegistryState? DecodeState(ReadOnlySpan data) + => data.Length == RegistryStateLength + ? new ElGamalRegistryState(new PublicKey(data[..PublicKey.Length]), data[PublicKey.Length..].ToArray()) + : null; + + private static sbyte AppendProofAccount(List accounts, ConfidentialProofLocation proofLocation) + { + if (proofLocation.IsInstructionOffset) + { + accounts.Add(AccountMeta.Readonly(InstructionsSysvar)); + return proofLocation.InstructionOffset; + } + + accounts.Add(AccountMeta.Readonly(proofLocation.ContextStateAccount!.Value)); + return 0; + } +} + +/// An SPL ElGamal registry account decoded without interpreting its 32-byte cryptographic key. +/// The wallet owner associated with the registry. +/// The exact 32-byte ElGamal public-key POD. +public sealed class ElGamalRegistryState(PublicKey owner, byte[] elGamalPublicKey) +{ + /// The wallet owner. + public PublicKey Owner { get; } = owner; + + /// The exact 32-byte ElGamal public-key POD. + public ReadOnlyMemory ElGamalPublicKey { get; } = elGamalPublicKey; +} diff --git a/src/SolSharp.Programs/ExtraAccountMeta.cs b/src/SolSharp.Programs/ExtraAccountMeta.cs new file mode 100644 index 0000000..6e06d75 --- /dev/null +++ b/src/SolSharp.Programs/ExtraAccountMeta.cs @@ -0,0 +1,375 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// The kind of seed encoded in an SPL TLV extra-account metadata entry. +public enum ExtraAccountSeedKind +{ + /// Literal bytes embedded in the metadata. + Literal, + + /// Bytes copied from the instruction data. + InstructionData, + + /// The public key of an account at an earlier account-list index. + AccountKey, + + /// Bytes copied from the data of an account at an earlier account-list index. + AccountData +} + +/// A seed configuration used to resolve an SPL extra-account PDA. +public sealed class ExtraAccountSeed +{ + private readonly byte[] _literalBytes; + + private ExtraAccountSeed( + ExtraAccountSeedKind kind, + byte[]? literalBytes = null, + byte index = 0, + byte length = 0, + byte accountIndex = 0, + byte dataIndex = 0) + { + Kind = kind; + _literalBytes = literalBytes ?? []; + Index = index; + Length = length; + AccountIndex = accountIndex; + DataIndex = dataIndex; + } + + /// The seed kind. + public ExtraAccountSeedKind Kind { get; } + + /// The literal bytes for a seed. + public ReadOnlyMemory LiteralBytes => _literalBytes; + + /// The instruction-data or account-key index, depending on . + public byte Index { get; } + + /// The number of bytes copied by an instruction-data or account-data seed. + public byte Length { get; } + + /// The source account index for an account-data seed. + public byte AccountIndex { get; } + + /// The byte offset within the source account data. + public byte DataIndex { get; } + + internal int EncodedLength => Kind switch + { + ExtraAccountSeedKind.Literal => 2 + _literalBytes.Length, + ExtraAccountSeedKind.InstructionData => 3, + ExtraAccountSeedKind.AccountKey => 2, + ExtraAccountSeedKind.AccountData => 4, + _ => throw new InvalidOperationException("Unknown extra-account seed kind.") + }; + + /// Creates a literal PDA seed. + /// The literal bytes; at most 30 bytes fit in one 32-byte address configuration. + /// The seed configuration. + /// contains more than 30 bytes. + public static ExtraAccountSeed Literal(ReadOnlySpan bytes) + { + if (bytes.Length > 30) + throw new ArgumentException("A literal extra-account seed may contain at most 30 bytes.", nameof(bytes)); + return new ExtraAccountSeed(ExtraAccountSeedKind.Literal, bytes.ToArray()); + } + + /// Creates a seed copied from instruction data. + /// The starting byte offset. + /// The number of bytes to copy. + /// The seed configuration. + public static ExtraAccountSeed FromInstructionData(byte index, byte length) + => new(ExtraAccountSeedKind.InstructionData, index: index, length: length); + + /// Creates a seed from the public key at an account-list index. + /// The account-list index. + /// The seed configuration. + public static ExtraAccountSeed FromAccountKey(byte index) + => new(ExtraAccountSeedKind.AccountKey, index: index); + + /// Creates a seed copied from an account's data. + /// The source account-list index. + /// The starting byte offset in the account data. + /// The number of bytes to copy. + /// The seed configuration. + public static ExtraAccountSeed FromAccountData(byte accountIndex, byte dataIndex, byte length) + => new( + ExtraAccountSeedKind.AccountData, + accountIndex: accountIndex, + dataIndex: dataIndex, + length: length); + + /// Decodes the packed seeds from a 32-byte SPL address configuration. + /// Exactly 32 configuration bytes. + /// The decoded seeds, or null when the configuration is malformed. + public static IReadOnlyList? DecodeConfiguration(ReadOnlySpan configuration) + { + if (configuration.Length != ExtraAccountMeta.AddressConfigurationLength) + return null; + + var seeds = new List(); + var offset = 0; + while (offset < configuration.Length) + { + var discriminator = configuration[offset]; + if (discriminator == 0) + return seeds; + + ExtraAccountSeed seed; + switch (discriminator) + { + case 1: + if (offset + 2 > configuration.Length) + return null; + var literalLength = configuration[offset + 1]; + if (offset + 2 + literalLength > configuration.Length) + return null; + seed = Literal(configuration.Slice(offset + 2, literalLength)); + break; + case 2: + if (offset + 3 > configuration.Length) + return null; + seed = FromInstructionData(configuration[offset + 1], configuration[offset + 2]); + break; + case 3: + if (offset + 2 > configuration.Length) + return null; + seed = FromAccountKey(configuration[offset + 1]); + break; + case 4: + if (offset + 4 > configuration.Length) + return null; + seed = FromAccountData( + configuration[offset + 1], + configuration[offset + 2], + configuration[offset + 3]); + break; + default: + return null; + } + + seeds.Add(seed); + offset += seed.EncodedLength; + } + + return seeds; + } + + internal void WriteTo(Span destination) + { + if (destination.Length != EncodedLength) + throw new ArgumentException("The seed destination has the wrong length.", nameof(destination)); + + switch (Kind) + { + case ExtraAccountSeedKind.Literal: + destination[0] = 1; + destination[1] = checked((byte)_literalBytes.Length); + _literalBytes.CopyTo(destination[2..]); + break; + case ExtraAccountSeedKind.InstructionData: + destination[0] = 2; + destination[1] = Index; + destination[2] = Length; + break; + case ExtraAccountSeedKind.AccountKey: + destination[0] = 3; + destination[1] = Index; + break; + case ExtraAccountSeedKind.AccountData: + destination[0] = 4; + destination[1] = AccountIndex; + destination[2] = DataIndex; + destination[3] = Length; + break; + default: + throw new InvalidOperationException("Unknown extra-account seed kind."); + } + } +} + +/// +/// The 35-byte POD entry used by SPL TLV account resolution to describe a fixed account, a PDA, or a +/// public key copied from instruction/account data. +/// +public sealed class ExtraAccountMeta +{ + /// The byte length of an encoded metadata entry. + public const int Length = 35; + + /// The byte length of the address configuration within an entry. + public const int AddressConfigurationLength = 32; + + private const byte ExternalProgramBit = 0x80; + private readonly byte[] _addressConfiguration; + private readonly byte _signerByte; + private readonly byte _writableByte; + + private ExtraAccountMeta(byte discriminator, byte[] addressConfiguration, byte signerByte, byte writableByte) + { + Discriminator = discriminator; + _addressConfiguration = addressConfiguration; + _signerByte = signerByte; + _writableByte = writableByte; + } + + /// + /// The entry discriminator: 0 for a fixed key, 1 for an executing-program PDA, 2 for key data, or + /// 128 plus an account index for an external-program PDA. + /// + public byte Discriminator { get; } + + /// The raw 32-byte address configuration. + public ReadOnlyMemory AddressConfiguration => _addressConfiguration; + + /// Whether the resolved account requests signer privilege before off-chain de-escalation. + public bool IsSigner => _signerByte != 0; + + /// Whether the resolved account requests writable privilege. + public bool IsWritable => _writableByte != 0; + + internal ReadOnlySpan AddressConfigurationSpan => _addressConfiguration; + + /// Creates an entry for a fixed public key. + /// The required account key. + /// Whether the entry requests signer privilege. + /// Whether the entry requests writable privilege. + /// The metadata entry. + public static ExtraAccountMeta FromPublicKey(PublicKey publicKey, bool isSigner, bool isWritable) + => new(0, publicKey.ToBytes(), BoolByte(isSigner), BoolByte(isWritable)); + + /// Creates an entry for a PDA owned by the program being invoked. + /// The PDA seed configurations. + /// Whether the entry requests signer privilege. + /// Whether the entry requests writable privilege. + /// The metadata entry. + /// or an element is null. + /// The packed seed configuration exceeds 32 bytes. + public static ExtraAccountMeta FromProgramDerivedAddress( + IReadOnlyList seeds, + bool isSigner, + bool isWritable) + => new(1, PackSeeds(seeds), BoolByte(isSigner), BoolByte(isWritable)); + + /// Creates an entry for a PDA owned by a program at an account-list index. + /// The index of the external program account; must be at most 127. + /// The PDA seed configurations. + /// Whether the entry requests signer privilege. + /// Whether the entry requests writable privilege. + /// The metadata entry. + /// exceeds 127. + /// or an element is null. + /// The packed seed configuration exceeds 32 bytes. + public static ExtraAccountMeta FromExternalProgramDerivedAddress( + byte programIndex, + IReadOnlyList seeds, + bool isSigner, + bool isWritable) + { + if (programIndex >= ExternalProgramBit) + throw new ArgumentOutOfRangeException(nameof(programIndex), programIndex, "An external program index must be at most 127."); + return new( + checked((byte)(ExternalProgramBit + programIndex)), + PackSeeds(seeds), + BoolByte(isSigner), + BoolByte(isWritable)); + } + + /// Creates an entry for a public key stored in instruction data. + /// The starting byte offset of the 32-byte key. + /// Whether the entry requests signer privilege. + /// Whether the entry requests writable privilege. + /// The metadata entry. + public static ExtraAccountMeta FromInstructionDataPublicKey(byte dataIndex, bool isSigner, bool isWritable) + { + var configuration = new byte[AddressConfigurationLength]; + configuration[0] = 1; + configuration[1] = dataIndex; + return new(2, configuration, BoolByte(isSigner), BoolByte(isWritable)); + } + + /// Creates an entry for a public key stored in account data. + /// The source account-list index. + /// The starting byte offset of the 32-byte key. + /// Whether the entry requests signer privilege. + /// Whether the entry requests writable privilege. + /// The metadata entry. + public static ExtraAccountMeta FromAccountDataPublicKey( + byte accountIndex, + byte dataIndex, + bool isSigner, + bool isWritable) + { + var configuration = new byte[AddressConfigurationLength]; + configuration[0] = 2; + configuration[1] = accountIndex; + configuration[2] = dataIndex; + return new(2, configuration, BoolByte(isSigner), BoolByte(isWritable)); + } + + /// Decodes one exact 35-byte SPL extra-account metadata entry. + /// The encoded entry. + /// The entry, or null when is not exactly 35 bytes. + public static ExtraAccountMeta? Decode(ReadOnlySpan data) + { + if (data.Length != Length) + return null; + return new ExtraAccountMeta(data[0], data.Slice(1, AddressConfigurationLength).ToArray(), data[33], data[34]); + } + + /// Encodes this entry in the pinned SPL 35-byte POD layout. + /// The encoded entry. + public byte[] Encode() + { + var data = new byte[Length]; + data[0] = Discriminator; + _addressConfiguration.CopyTo(data, 1); + data[33] = _signerByte; + data[34] = _writableByte; + return data; + } + + /// Attempts to read a fixed public key from this entry. + /// The fixed key on success. + /// true when this is a fixed-key entry. + public bool TryGetPublicKey(out PublicKey publicKey) + { + if (Discriminator == 0) + { + publicKey = new PublicKey(_addressConfiguration); + return true; + } + + publicKey = default; + return false; + } + + /// Decodes this entry's PDA seeds. + /// The seed list, or null when the entry is not a PDA or its configuration is malformed. + public IReadOnlyList? DecodeSeeds() + => Discriminator is 1 or >= ExternalProgramBit + ? ExtraAccountSeed.DecodeConfiguration(_addressConfiguration) + : null; + + private static byte BoolByte(bool value) => value ? (byte)1 : (byte)0; + + private static byte[] PackSeeds(IReadOnlyList seeds) + { + ArgumentNullException.ThrowIfNull(seeds); + var configuration = new byte[AddressConfigurationLength]; + var offset = 0; + for (var i = 0; i < seeds.Count; i++) + { + var seed = seeds[i] ?? throw new ArgumentNullException(nameof(seeds), $"Seed at index {i} is null."); + if (offset + seed.EncodedLength > configuration.Length) + throw new ArgumentException("Packed extra-account seed configurations may occupy at most 32 bytes.", nameof(seeds)); + seed.WriteTo(configuration.AsSpan(offset, seed.EncodedLength)); + offset += seed.EncodedLength; + } + + return configuration; + } +} diff --git a/src/SolSharp.Programs/FeatureAccountState.cs b/src/SolSharp.Programs/FeatureAccountState.cs new file mode 100644 index 0000000..7af9f14 --- /dev/null +++ b/src/SolSharp.Programs/FeatureAccountState.cs @@ -0,0 +1,58 @@ +using System.Buffers.Binary; + +namespace SolSharp.Programs; + +/// The canonical nine-byte state stored by a Solana runtime feature account. +public sealed record FeatureAccountState +{ + /// The fixed feature-account allocation used by the pinned SDK. + public const int DataLength = 9; + + private FeatureAccountState(ulong? activatedAt) + { + ActivatedAt = activatedAt; + } + + /// + /// The slot at which the feature became active, or null while activation has only been requested. + /// + public ulong? ActivatedAt { get; } + + /// true when the feature account records an activation slot. + public bool IsActive => ActivatedAt.HasValue; + + /// Decodes a feature account's bincode state. + /// At least nine account-data bytes. + /// The decoded feature state. + /// The data is too short or has a non-canonical option tag. + public static FeatureAccountState Parse(ReadOnlySpan data) + { + if (data.Length < DataLength) + throw new ArgumentException($"Feature account data must be at least {DataLength} bytes.", nameof(data)); + + return data[0] switch + { + 0 => new FeatureAccountState((ulong?)null), + 1 => new FeatureAccountState(BinaryPrimitives.ReadUInt64LittleEndian(data[1..])), + _ => throw new ArgumentException("Feature activation option tag must be 0 or 1.", nameof(data)) + }; + } + + /// Tries to decode a feature account without throwing. + /// The account data. + /// The decoded state on success; otherwise null. + /// true when the state is canonical. + public static bool TryParse(ReadOnlySpan data, out FeatureAccountState? state) + { + try + { + state = Parse(data); + return true; + } + catch (ArgumentException) + { + state = null; + return false; + } + } +} diff --git a/src/SolSharp.Programs/FeatureGateProgram.cs b/src/SolSharp.Programs/FeatureGateProgram.cs new file mode 100644 index 0000000..69086ef --- /dev/null +++ b/src/SolSharp.Programs/FeatureGateProgram.cs @@ -0,0 +1,48 @@ +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Builds client-side feature-gate activation and pending-activation revocation instructions. +public static class FeatureGateProgram +{ + /// The runtime feature program address. + public static readonly PublicKey ProgramId = PublicKey.Parse(SolanaProgramIds.FeatureProgram); + + /// The incinerator account that receives lamports from revoked pending activations. + public static readonly PublicKey IncineratorId = + PublicKey.Parse("1nc1nerator11111111111111111111111111111111"); + + /// + /// Builds the pinned three-instruction activation sequence: transfer funding, allocate nine bytes, then assign + /// the feature account to the feature program. + /// + /// The new feature account; writable and required to sign all account mutations. + /// The writable signer that funds the feature account. + /// The exact number of lamports to transfer. + /// The transfer, allocate, and assign instructions in canonical order. + public static Instruction[] ActivateWithLamports( + PublicKey featureId, + PublicKey fundingAddress, + ulong lamports) => + [ + SystemProgram.Transfer(fundingAddress, featureId, lamports), + SystemProgram.Allocate(featureId, FeatureAccountState.DataLength), + SystemProgram.Assign(featureId, ProgramId) + ]; + + /// Builds the feature program's pending-activation revocation instruction. + /// The pending feature account; writable and required to sign. + /// The revocation instruction with feature, incinerator, and System Program account metas. + public static Instruction RevokePendingActivation(PublicKey featureId) => new() + { + ProgramId = ProgramId, + Accounts = + [ + AccountMeta.WritableSigner(featureId), + AccountMeta.Writable(IncineratorId), + AccountMeta.Readonly(SystemProgram.ProgramId) + ], + Data = [0] + }; +} diff --git a/src/SolSharp.Programs/ITransactionMessage.cs b/src/SolSharp.Programs/ITransactionMessage.cs index dd1e9eb..8b7f92e 100644 --- a/src/SolSharp.Programs/ITransactionMessage.cs +++ b/src/SolSharp.Programs/ITransactionMessage.cs @@ -3,8 +3,8 @@ namespace SolSharp.Programs; /// -/// A compiled transaction message — a legacy or a versioned — -/// that a can sign and serialize. +/// A compiled transaction message — legacy , versioned , +/// or SIMD-0385 — that a can sign and serialize. /// public interface ITransactionMessage { @@ -35,7 +35,7 @@ public interface ITransactionMessage /// /// Resolves the compiled instructions back into s, mapping each account index to /// its public key and signer/writable flags. A v0 message additionally loads accounts from the supplied - /// address lookup tables (pass every table the message references); a legacy message ignores them. + /// address lookup tables (pass every table the message references); legacy and V1 messages ignore them. /// /// The resolved lookup tables the message references. /// The resolved instructions, in order. diff --git a/src/SolSharp.Programs/InstructionsSysvar.cs b/src/SolSharp.Programs/InstructionsSysvar.cs new file mode 100644 index 0000000..3ddb51e --- /dev/null +++ b/src/SolSharp.Programs/InstructionsSysvar.cs @@ -0,0 +1,193 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// +/// Constructs and decodes the native Instructions sysvar account layout used for transaction +/// instruction introspection. +/// +public static class InstructionsSysvar +{ + private const int HeaderValueLength = sizeof(ushort); + private const int AccountMetaLength = 1 + PublicKey.Length; + private const byte SignerFlag = 1; + private const byte WritableFlag = 2; + + /// Constructs complete Instructions sysvar data, including the trailing current index. + /// The transaction instructions in execution order. + /// The index exposed as the currently executing instruction. + /// The exact native sysvar account bytes. + /// or one of its entries is null. + /// + /// A collection or instruction-data length exceeds its 16-bit wire field, or an instruction offset + /// cannot be represented by the native layout. + /// + public static byte[] Serialize( + IReadOnlyList instructions, + ushort currentInstructionIndex = 0) + { + ArgumentNullException.ThrowIfNull(instructions); + if (instructions.Count > ushort.MaxValue) + throw new ArgumentException("The Instructions sysvar can encode at most 65,535 instructions.", nameof(instructions)); + + var headerLength = checked(HeaderValueLength + (instructions.Count * sizeof(ushort))); + using var stream = new MemoryStream(headerLength + HeaderValueLength); + stream.SetLength(headerLength); + stream.Position = headerLength; + WriteUInt16(stream.GetBuffer(), 0, (ushort)instructions.Count); + + for (var i = 0; i < instructions.Count; i++) + { + var instruction = instructions[i] + ?? throw new ArgumentNullException(nameof(instructions), $"Instruction at index {i} is null."); + var accounts = instruction.Accounts + ?? throw new ArgumentException($"Instruction {i} has null accounts.", nameof(instructions)); + var instructionData = instruction.Data + ?? throw new ArgumentException($"Instruction {i} has null data.", nameof(instructions)); + if (accounts.Count > ushort.MaxValue) + throw new ArgumentException($"Instruction {i} has more than 65,535 accounts.", nameof(instructions)); + if (instructionData.Length > ushort.MaxValue) + throw new ArgumentException($"Instruction {i} data exceeds 65,535 bytes.", nameof(instructions)); + if (stream.Length > ushort.MaxValue) + throw new ArgumentException($"Instruction {i} starts beyond the 16-bit sysvar offset range.", nameof(instructions)); + + WriteUInt16(stream.GetBuffer(), HeaderValueLength + (i * sizeof(ushort)), (ushort)stream.Length); + WriteUInt16(stream, (ushort)accounts.Count); + foreach (var account in accounts) + { + var flags = (byte)0; + if (account.IsSigner) + flags |= SignerFlag; + if (account.IsWritable) + flags |= WritableFlag; + stream.WriteByte(flags); + WritePublicKey(stream, account.PublicKey); + } + + WritePublicKey(stream, instruction.ProgramId); + WriteUInt16(stream, (ushort)instructionData.Length); + stream.Write(instructionData); + } + + WriteUInt16(stream, currentInstructionIndex); + return stream.ToArray(); + } + + /// Reads the number of instructions declared by sysvar data. + /// The complete Instructions sysvar account data. + /// The declared instruction count. + /// The fixed header or offset table is truncated. + public static ushort GetInstructionCount(ReadOnlySpan data) + { + EnsureAvailable(data, 0, HeaderValueLength, "instruction count"); + var count = BinaryPrimitives.ReadUInt16LittleEndian(data); + EnsureAvailable(data, HeaderValueLength, count * sizeof(ushort), "instruction offset table"); + return count; + } + + /// Reads the trailing current-instruction index. + /// The complete Instructions sysvar account data. + /// The current instruction index. + /// The data is shorter than the two-byte index. + public static ushort ReadCurrentInstructionIndex(ReadOnlySpan data) + { + if (data.Length < HeaderValueLength) + throw new FormatException("Instructions sysvar data is too short for its current index."); + return BinaryPrimitives.ReadUInt16LittleEndian(data[^HeaderValueLength..]); + } + + /// Writes the trailing current-instruction index in caller-owned sysvar data. + /// The complete writable Instructions sysvar account data. + /// The index to store. + /// The data is shorter than the two-byte index. + public static void WriteCurrentInstructionIndex(Span data, ushort currentInstructionIndex) + { + if (data.Length < HeaderValueLength) + throw new ArgumentException("Instructions sysvar data is too short for its current index.", nameof(data)); + BinaryPrimitives.WriteUInt16LittleEndian(data[^HeaderValueLength..], currentInstructionIndex); + } + + /// Decodes the instruction at an absolute transaction index. + /// The complete Instructions sysvar account data. + /// The zero-based instruction index. + /// The decoded instruction. + /// is outside the declared table. + /// The table, instruction, account metadata, or data slice is malformed. + public static Instruction ReadInstruction(ReadOnlySpan data, int index) + { + var count = GetInstructionCount(data); + if ((uint)index >= count) + throw new ArgumentOutOfRangeException(nameof(index), index, $"Instruction index must be below {count}."); + + var offsetPosition = HeaderValueLength + (index * sizeof(ushort)); + var offset = BinaryPrimitives.ReadUInt16LittleEndian(data[offsetPosition..]); + EnsureAvailable(data, offset, HeaderValueLength, "account count"); + var accountCount = BinaryPrimitives.ReadUInt16LittleEndian(data[offset..]); + var cursor = offset + HeaderValueLength; + var minimumLength = ((long)accountCount * AccountMetaLength) + PublicKey.Length + HeaderValueLength; + if (minimumLength > data.Length - cursor) + throw new FormatException("Instructions sysvar account metadata is truncated."); + + var accounts = new AccountMeta[accountCount]; + for (var i = 0; i < accounts.Length; i++) + { + var flags = data[cursor++]; + var key = new PublicKey(data.Slice(cursor, PublicKey.Length)); + cursor += PublicKey.Length; + accounts[i] = new AccountMeta( + key, + isSigner: (flags & SignerFlag) != 0, + isWritable: (flags & WritableFlag) != 0); + } + + var programId = new PublicKey(data.Slice(cursor, PublicKey.Length)); + cursor += PublicKey.Length; + var dataLength = BinaryPrimitives.ReadUInt16LittleEndian(data[cursor..]); + cursor += HeaderValueLength; + EnsureAvailable(data, cursor, dataLength, "instruction data"); + return new Instruction + { + ProgramId = programId, + Accounts = accounts, + Data = data.Slice(cursor, dataLength).ToArray() + }; + } + + /// Decodes an instruction relative to the trailing current-instruction index. + /// The complete Instructions sysvar account data. + /// A signed offset from the current instruction. + /// The decoded relative instruction. + /// The resulting instruction index is negative or outside the table. + /// The sysvar data is malformed. + public static Instruction ReadInstructionRelative(ReadOnlySpan data, int relativeIndex) + { + var index = (long)ReadCurrentInstructionIndex(data) + relativeIndex; + if (index is < 0 or > int.MaxValue) + throw new ArgumentOutOfRangeException(nameof(relativeIndex), relativeIndex, "The relative instruction index is outside the table."); + return ReadInstruction(data, (int)index); + } + + private static void WriteUInt16(Stream stream, ushort value) + { + Span bytes = stackalloc byte[sizeof(ushort)]; + BinaryPrimitives.WriteUInt16LittleEndian(bytes, value); + stream.Write(bytes); + } + + private static void WriteUInt16(Span destination, int offset, ushort value) + => BinaryPrimitives.WriteUInt16LittleEndian(destination[offset..], value); + + private static void WritePublicKey(Stream stream, PublicKey key) + { + Span bytes = stackalloc byte[PublicKey.Length]; + key.CopyTo(bytes); + stream.Write(bytes); + } + + private static void EnsureAvailable(ReadOnlySpan data, int offset, int length, string field) + { + if (offset < 0 || length < 0 || offset > data.Length - length) + throw new FormatException($"Instructions sysvar data is truncated in its {field}."); + } +} diff --git a/src/SolSharp.Programs/LegacyBpfLoaderProgram.cs b/src/SolSharp.Programs/LegacyBpfLoaderProgram.cs new file mode 100644 index 0000000..9aca2c0 --- /dev/null +++ b/src/SolSharp.Programs/LegacyBpfLoaderProgram.cs @@ -0,0 +1,57 @@ +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Builds instructions for the deprecated non-upgradeable BPF loader interface. +public static class LegacyBpfLoaderProgram +{ + private static readonly PublicKey RentSysvar = PublicKey.Parse(Sysvars.Rent); + + /// The BPF Loader 2 address used by the final non-upgradeable loader. + public static readonly PublicKey ProgramId = + PublicKey.Parse("BPFLoader2111111111111111111111111111111111"); + + /// The original BPF loader address accepted by the generic legacy builders. + public static readonly PublicKey OriginalProgramId = + PublicKey.Parse("BPFLoader1111111111111111111111111111111111"); + + /// Writes program bytes into a legacy loader-owned account. + /// The writable program-account signer. + /// The byte offset at which to write. + /// The program bytes. + /// The selected legacy loader; defaults to BPF Loader 2. + /// The legacy write instruction. + [Obsolete("The non-upgradeable BPF loaders are deprecated; use LoaderV4Program instead.")] + public static Instruction Write( + PublicKey programAccount, + uint offset, + ReadOnlySpan bytes, + PublicKey? loaderProgramId = null) + { + var payload = bytes.ToArray(); + return new Instruction + { + ProgramId = loaderProgramId ?? ProgramId, + Accounts = [AccountMeta.WritableSigner(programAccount)], + Data = ProgramWireEncoding.Build(0, stream => + { + ProgramWireEncoding.WriteUInt32(stream, offset); + ProgramWireEncoding.WriteByteVector(stream, payload); + }) + }; + } + + /// Finalizes a legacy loader-owned program account for execution. + /// The writable program-account signer. + /// The selected legacy loader; defaults to BPF Loader 2. + /// The legacy finalize instruction. + [Obsolete("The non-upgradeable BPF loaders are deprecated; use LoaderV4Program instead.")] + public static Instruction Finalize(PublicKey programAccount, PublicKey? loaderProgramId = null) + => new() + { + ProgramId = loaderProgramId ?? ProgramId, + Accounts = [AccountMeta.WritableSigner(programAccount), AccountMeta.Readonly(RentSysvar)], + Data = ProgramWireEncoding.Build(1) + }; +} diff --git a/src/SolSharp.Programs/LoaderV4Program.cs b/src/SolSharp.Programs/LoaderV4Program.cs new file mode 100644 index 0000000..727d163 --- /dev/null +++ b/src/SolSharp.Programs/LoaderV4Program.cs @@ -0,0 +1,180 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Builds instructions for Solana's Loader V4 program-management interface. +public static class LoaderV4Program +{ + private const uint WriteDiscriminator = 0; + private const uint CopyDiscriminator = 1; + private const uint SetProgramLengthDiscriminator = 2; + private const uint DeployDiscriminator = 3; + private const uint RetractDiscriminator = 4; + private const uint TransferAuthorityDiscriminator = 5; + private const uint FinalizeDiscriminator = 6; + + /// The Loader V4 program address. + public static readonly PublicKey ProgramId = + PublicKey.Parse("LoaderV411111111111111111111111111111111111"); + + /// The minimum slot cooldown between deployment-state changes. + public const ulong DeploymentCooldownSlots = 1; + + /// Creates a Loader V4 account and initializes its program-data length. + /// The funding signer. + /// The new program-account signer. + /// The lamports to fund. + /// The program-authority signer. + /// The initial program-data length. + /// The writable recipient of any excess lamports. + /// The System create instruction followed by set-program-length. + public static Instruction[] CreateBuffer( + PublicKey payer, + PublicKey programAccount, + ulong lamports, + PublicKey authority, + uint programLength, + PublicKey recipient) + => + [ + SystemProgram.CreateAccount(payer, programAccount, lamports, 0, ProgramId), + SetProgramLength(programAccount, authority, programLength, recipient) + ]; + + /// Changes the size of a retracted program account. + /// The writable program account. + /// The program-authority signer. + /// The new program-data length. + /// The writable account receiving excess lamports. + /// The set-program-length instruction. + public static Instruction SetProgramLength( + PublicKey programAccount, + PublicKey authority, + uint newLength, + PublicKey recipient) + => CreateInstruction( + ProgramWireEncoding.Build( + SetProgramLengthDiscriminator, + stream => ProgramWireEncoding.WriteUInt32(stream, newLength)), + [ + AccountMeta.Writable(programAccount), + AccountMeta.ReadonlySigner(authority), + AccountMeta.Writable(recipient) + ]); + + /// Writes ELF bytes into a retracted program account. + /// The writable program account. + /// The program-authority signer. + /// The destination byte offset. + /// The ELF bytes. + /// The write instruction. + public static Instruction Write( + PublicKey programAccount, + PublicKey authority, + uint offset, + ReadOnlySpan bytes) + { + var payload = bytes.ToArray(); + return CreateInstruction( + ProgramWireEncoding.Build(WriteDiscriminator, stream => + { + ProgramWireEncoding.WriteUInt32(stream, offset); + ProgramWireEncoding.WriteByteVector(stream, payload); + }), + [AccountMeta.Writable(programAccount), AccountMeta.ReadonlySigner(authority)]); + } + + /// Copies ELF bytes from another program account. + /// The writable destination program. + /// The destination program-authority signer. + /// The read-only source program. + /// The destination byte offset. + /// The source byte offset. + /// The number of bytes to copy. + /// The copy instruction. + public static Instruction Copy( + PublicKey programAccount, + PublicKey authority, + PublicKey sourceProgram, + uint destinationOffset, + uint sourceOffset, + uint length) + => CreateInstruction( + ProgramWireEncoding.Build(CopyDiscriminator, stream => + { + ProgramWireEncoding.WriteUInt32(stream, destinationOffset); + ProgramWireEncoding.WriteUInt32(stream, sourceOffset); + ProgramWireEncoding.WriteUInt32(stream, length); + }), + [ + AccountMeta.Writable(programAccount), + AccountMeta.ReadonlySigner(authority), + AccountMeta.Readonly(sourceProgram) + ]); + + /// Verifies and deploys a program, optionally consuming a source program account. + /// The writable program to deploy. + /// The program-authority signer. + /// An optional writable source whose bytes and lamports are consumed. + /// The deploy instruction. + public static Instruction Deploy( + PublicKey programAccount, + PublicKey authority, + PublicKey? sourceProgram = null) + { + var accounts = new List + { + AccountMeta.Writable(programAccount), + AccountMeta.ReadonlySigner(authority) + }; + if (sourceProgram is { } source) + accounts.Add(AccountMeta.Writable(source)); + return CreateInstruction(ProgramWireEncoding.Build(DeployDiscriminator), accounts); + } + + /// Retracts a deployed program into writable maintenance mode. + /// The writable deployed program. + /// The program-authority signer. + /// The retract instruction. + public static Instruction Retract(PublicKey programAccount, PublicKey authority) + => CreateInstruction( + ProgramWireEncoding.Build(RetractDiscriminator), + [AccountMeta.Writable(programAccount), AccountMeta.ReadonlySigner(authority)]); + + /// Transfers management authority to a new signer. + /// The writable program account. + /// The current authority signer. + /// The replacement authority signer. + /// The transfer-authority instruction. + public static Instruction TransferAuthority( + PublicKey programAccount, + PublicKey authority, + PublicKey newAuthority) + => CreateInstruction( + ProgramWireEncoding.Build(TransferAuthorityDiscriminator), + [ + AccountMeta.Writable(programAccount), + AccountMeta.ReadonlySigner(authority), + AccountMeta.ReadonlySigner(newAuthority) + ]); + + /// Finalizes a program permanently and records its next version. + /// The writable program account. + /// The current authority signer. + /// The read-only next-version program, which may be the program itself. + /// The finalize instruction. + public static Instruction Finalize( + PublicKey programAccount, + PublicKey authority, + PublicKey nextVersionProgram) + => CreateInstruction( + ProgramWireEncoding.Build(FinalizeDiscriminator), + [ + AccountMeta.Writable(programAccount), + AccountMeta.ReadonlySigner(authority), + AccountMeta.Readonly(nextVersionProgram) + ]); + + private static Instruction CreateInstruction(byte[] data, IReadOnlyList accounts) + => new() { ProgramId = ProgramId, Accounts = accounts, Data = data }; +} diff --git a/src/SolSharp.Programs/LoaderV4State.cs b/src/SolSharp.Programs/LoaderV4State.cs new file mode 100644 index 0000000..bc5521e --- /dev/null +++ b/src/SolSharp.Programs/LoaderV4State.cs @@ -0,0 +1,94 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// The deployment status stored in a Loader V4 program account. +public enum LoaderV4Status : ulong +{ + /// The program is in writable maintenance mode. + Retracted = 0, + + /// The program is deployed and executable. + Deployed = 1, + + /// The program is executable and permanently immutable. + Finalized = 2 +} + +/// Decoded native-layout metadata and program bytes from a Loader V4 account. +public sealed class LoaderV4State +{ + private readonly byte[] _programBytes; + + private LoaderV4State( + ulong slot, + PublicKey authorityOrNextVersion, + LoaderV4Status status, + ReadOnlySpan programBytes) + { + Slot = slot; + AuthorityOrNextVersion = authorityOrNextVersion; + Status = status; + _programBytes = programBytes.ToArray(); + } + + /// The native metadata header length. + public const int MetadataLength = 48; + + /// The slot in which the account was last deployed, retracted, or initialized. + public ulong Slot { get; } + + /// The management authority, or the next-version address for a finalized program. + public PublicKey AuthorityOrNextVersion { get; } + + /// The program deployment status. + public LoaderV4Status Status { get; } + + /// The ELF bytes following the 48-byte native metadata header. + public ReadOnlyMemory ProgramBytes => _programBytes; + + /// Decodes Loader V4 account data using its fixed native layout. + /// The complete program-account data. + /// The decoded state. + /// The input is shorter than 48 bytes. + /// The status value is unknown. + public static LoaderV4State Parse(ReadOnlySpan data) + { + if (data.Length < MetadataLength) + throw new ArgumentException($"Loader V4 state requires at least {MetadataLength} bytes.", nameof(data)); + + var statusValue = BinaryPrimitives.ReadUInt64LittleEndian(data[40..]); + if (statusValue > (ulong)LoaderV4Status.Finalized) + throw new FormatException($"Unknown Loader V4 status {statusValue}."); + + return new LoaderV4State( + BinaryPrimitives.ReadUInt64LittleEndian(data), + new PublicKey(data.Slice(8, PublicKey.Length)), + (LoaderV4Status)statusValue, + data[MetadataLength..]); + } + + /// Attempts to decode Loader V4 account data. + /// The complete program-account data. + /// The decoded state on success; otherwise null. + /// true when the data contains a valid Loader V4 header. + public static bool TryParse(ReadOnlySpan data, out LoaderV4State? state) + { + try + { + state = Parse(data); + return true; + } + catch (ArgumentException) + { + state = null; + return false; + } + catch (FormatException) + { + state = null; + return false; + } + } +} diff --git a/src/SolSharp.Programs/MemoProgram.cs b/src/SolSharp.Programs/MemoProgram.cs index a86896a..c12589f 100644 --- a/src/SolSharp.Programs/MemoProgram.cs +++ b/src/SolSharp.Programs/MemoProgram.cs @@ -1,3 +1,4 @@ +using System.Text; using SolSharp.Core.Primitives; namespace SolSharp.Programs; @@ -5,6 +6,8 @@ namespace SolSharp.Programs; /// Builds instructions for the SPL Memo program: attaches a UTF-8 memo to a transaction, optionally signed. public static class MemoProgram { + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + /// The SPL Memo program's address. public static readonly PublicKey ProgramId = PublicKey.Parse("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"); @@ -17,11 +20,37 @@ public static class MemoProgram /// The accounts that must sign the memo; pass none for an unsigned memo. /// The memo instruction. /// or is null. + /// contains invalid Unicode text. public static Instruction Memo(string text, params PublicKey[] signers) { ArgumentNullException.ThrowIfNull(text); ArgumentNullException.ThrowIfNull(signers); + byte[] data; + try + { + data = StrictUtf8.GetBytes(text); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException("A memo must contain valid Unicode text.", nameof(text), exception); + } + + return Memo(data, signers); + } + + /// + /// Builds a memo instruction from raw bytes. The Memo program validates UTF-8 on-chain; accepting bytes here + /// preserves exact client-side parity with the Rust builder and permits callers to handle validation timing. + /// + /// The exact instruction bytes. + /// The read-only accounts that must sign the memo. + /// The memo instruction. + /// is null. + public static Instruction Memo(ReadOnlySpan memo, params PublicKey[] signers) + { + ArgumentNullException.ThrowIfNull(signers); + // Read-only signers, matching the Rust spl-memo builder (AccountMeta::new_readonly(pubkey, true)); // a writable flag would needlessly write-lock the signer accounts. (solana-py marks them writable.) var accounts = new AccountMeta[signers.Length]; @@ -32,7 +61,7 @@ public static Instruction Memo(string text, params PublicKey[] signers) { ProgramId = ProgramId, Accounts = accounts, - Data = System.Text.Encoding.UTF8.GetBytes(text) + Data = memo.ToArray() }; } } diff --git a/src/SolSharp.Programs/Message.cs b/src/SolSharp.Programs/Message.cs index e9faefa..ca0eed2 100644 --- a/src/SolSharp.Programs/Message.cs +++ b/src/SolSharp.Programs/Message.cs @@ -5,7 +5,7 @@ namespace SolSharp.Programs; /// /// A compiled legacy Solana transaction message: the ordered account list, the header counts, the recent -/// blockhash, and the compiled instructions. Build one with , then serialize it with +/// blockhash, and the compiled instructions. Build one with Compile, then serialize it with /// to get the bytes that are signed and sent. /// public sealed class Message : ITransactionMessage @@ -54,11 +54,29 @@ private Message( /// non-signers, read-only non-signers - then indexes each instruction against that list. /// /// The account that pays the fee; always the first account and a writable signer. + /// A recent blockhash, e.g. from getLatestBlockhash. + /// The instructions to include, in execution order. + /// The compiled message. + /// is null. + /// + /// The instructions reference more than distinct accounts or require more + /// than 127 signatures, whose high bit would collide with the versioned-message prefix. + /// + public static Message Compile(PublicKey feePayer, Hash recentBlockhash, IReadOnlyList instructions) + => Compile(feePayer, recentBlockhash.ToString(), instructions); + + /// + /// Compiles a set of instructions into a legacy message using a base58 blockhash string. + /// + /// The account that pays the fee; always the first account and a writable signer. /// A recent blockhash (base58), e.g. from getLatestBlockhash. /// The instructions to include, in execution order. /// The compiled message. /// or is null. - /// The instructions reference more than distinct accounts. + /// + /// The instructions reference more than distinct accounts or require more + /// than 127 signatures, whose high bit would collide with the versioned-message prefix. + /// public static Message Compile(PublicKey feePayer, string recentBlockhash, IReadOnlyList instructions) { ArgumentNullException.ThrowIfNull(recentBlockhash); @@ -97,7 +115,9 @@ void Merge(PublicKey key, bool signer, bool writable) AddClass(orderedKeys, rest, flags, signer: false, writable: true); AddClass(orderedKeys, rest, flags, signer: false, writable: false); - byte requiredSignatures = 0, readonlySigned = 0, readonlyUnsigned = 0; + var requiredSignatures = 0; + var readonlySigned = 0; + var readonlyUnsigned = 0; var finalPosition = new Dictionary(orderedKeys.Count); for (var slot = 0; slot < orderedKeys.Count; slot++) { @@ -117,6 +137,14 @@ void Merge(PublicKey key, bool signer, bool writable) } } + // A legacy message has no separate version byte: the high bit of its first header byte is + // the versioned-message discriminator. Keep the signer count below it so the serialized + // message cannot be mistaken for v0 (or a future version). + if (requiredSignatures >= MessageV0.VersionPrefix) + throw new ArgumentException( + $"A legacy message can require at most {MessageV0.VersionPrefix - 1} signatures, got {requiredSignatures}.", + nameof(instructions)); + var compiled = new CompiledInstruction[instructions.Count]; for (var n = 0; n < instructions.Count; n++) { @@ -129,11 +157,17 @@ void Merge(PublicKey key, bool signer, bool writable) { ProgramIdIndex = (byte)finalPosition[instruction.ProgramId], AccountIndexes = accountIndexes, - Data = instruction.Data + Data = [.. instruction.Data] }; } - return new Message(requiredSignatures, readonlySigned, readonlyUnsigned, orderedKeys, recentBlockhash, compiled); + return new Message( + (byte)requiredSignatures, + (byte)readonlySigned, + (byte)readonlyUnsigned, + orderedKeys, + recentBlockhash, + compiled); } /// Serializes the message to its canonical wire bytes - the bytes a signer signs over. @@ -151,7 +185,7 @@ public byte[] Serialize() public int GetSerializedLength() { var length = 3 // the header counts - + ShortVec.GetByteCount(AccountKeys.Count) + AccountKeys.Count * PublicKey.Length + + ShortVec.GetByteCount(AccountKeys.Count) + (AccountKeys.Count * PublicKey.Length) + PublicKey.Length // the recent blockhash + ShortVec.GetByteCount(Instructions.Count); @@ -223,7 +257,7 @@ public IReadOnlyList DecompileInstructions(IReadOnlyListThe serialized message (no version prefix). /// The parsed message. /// - /// The data is truncated, a compact-u16 length in it is malformed, or the message breaks a rule + /// The data is truncated, contains trailing bytes, has a malformed compact-u16 length, or breaks a rule /// Solana's sanitize enforces: header counts that overlap the account list or leave no writable /// fee-payer signer, an instruction whose program id is the fee payer, or an out-of-range program id /// or account index. @@ -234,6 +268,11 @@ public static Message Deserialize(ReadOnlySpan data) { var offset = 0; var requiredSignatures = data[offset++]; + if ((requiredSignatures & MessageV0.VersionPrefix) != 0) + throw new FormatException( + $"A legacy message signer count must be below {MessageV0.VersionPrefix}; " + + $"the high bit marks a versioned message, got {requiredSignatures}."); + var readonlySignedAccounts = data[offset++]; var readonlyUnsignedAccounts = data[offset++]; @@ -244,6 +283,9 @@ public static Message Deserialize(ReadOnlySpan data) var instructions = MessageWire.ReadInstructions(data, ref offset); + if (offset != data.Length) + throw new FormatException($"The message has {data.Length - offset} trailing byte(s)."); + // Mirror Solana's sanitize so a message the network would refuse never parses successfully. MessageWire.SanitizeHeader(requiredSignatures, readonlySignedAccounts, readonlyUnsignedAccounts, accountKeys.Length); MessageWire.SanitizeInstructions(instructions, accountKeys.Length, accountKeys.Length); diff --git a/src/SolSharp.Programs/MessageDecompiler.cs b/src/SolSharp.Programs/MessageDecompiler.cs index b978504..0d9dead 100644 --- a/src/SolSharp.Programs/MessageDecompiler.cs +++ b/src/SolSharp.Programs/MessageDecompiler.cs @@ -2,8 +2,8 @@ namespace SolSharp.Programs; -// Resolves compiled instructions back into Instructions for both message formats. The combined account index -// space is: static keys, then loaded-writable, then loaded-readonly (for legacy there is no loaded section). +// Resolves compiled instructions back into Instructions for every message format. The combined account index +// space is: static keys, then loaded-writable, then loaded-readonly (legacy and V1 have no loaded section). internal static class MessageDecompiler { public static IReadOnlyList Decompile( @@ -33,7 +33,7 @@ public static IReadOnlyList Decompile( { ProgramId = KeyAt(keys, compiled.ProgramIdIndex), Accounts = accounts, - Data = compiled.Data + Data = [.. compiled.Data] }; } diff --git a/src/SolSharp.Programs/MessageV0.cs b/src/SolSharp.Programs/MessageV0.cs index 65442ec..1aa8e7a 100644 --- a/src/SolSharp.Programs/MessageV0.cs +++ b/src/SolSharp.Programs/MessageV0.cs @@ -1,3 +1,4 @@ +using System.Buffers.Binary; using SolSharp.Core.Encoding; using SolSharp.Core.Primitives; @@ -6,7 +7,7 @@ namespace SolSharp.Programs; /// /// A compiled v0 (versioned) transaction message: like a legacy , but able to load /// extra accounts from on-chain address lookup tables so a transaction can reference far more accounts. -/// Build one with , then for the signed-and-sent bytes, which +/// Build one with Compile, then for the signed-and-sent bytes, which /// begin with the byte. /// public sealed class MessageV0 : ITransactionMessage @@ -64,12 +65,36 @@ private MessageV0( /// non-signers - and indexes each instruction against the static keys followed by the loaded accounts. /// /// The account that pays the fee; always the first static account and a writable signer. + /// A recent blockhash, e.g. from getLatestBlockhash. + /// The instructions to include, in execution order. + /// The lookup tables to source extra accounts from; pass an empty list for none. + /// The compiled v0 message. + /// or is null. + /// + /// The instructions reference more than distinct accounts, require more than + /// 255 signatures, or a supplied lookup table holds more than addresses. + /// + public static MessageV0 Compile( + PublicKey feePayer, + Hash recentBlockhash, + IReadOnlyList instructions, + IReadOnlyList addressLookupTables) + => Compile(feePayer, recentBlockhash.ToString(), instructions, addressLookupTables); + + /// + /// Compiles instructions into a v0 message using a base58 blockhash string and optional address + /// lookup tables. + /// + /// The account that pays the fee; always the first static account and a writable signer. /// A recent blockhash (base58), e.g. from getLatestBlockhash. /// The instructions to include, in execution order. /// The lookup tables to source extra accounts from; pass an empty list for none. /// The compiled v0 message. /// , , or is null. - /// The instructions reference more than distinct accounts, or a supplied lookup table holds more than addresses. + /// + /// The instructions reference more than distinct accounts, require more than + /// 255 signatures, or a supplied lookup table holds more than addresses. + /// public static MessageV0 Compile( PublicKey feePayer, string recentBlockhash, @@ -96,6 +121,15 @@ void Merge(PublicKey key, bool signer, bool writable, bool invoked) Merge(account.PublicKey, account.IsSigner, account.IsWritable, invoked: false); } + // The runtime resolves a durable nonce before loading address tables, so the nonce account + // named by a first System instruction whose data starts with the AdvanceNonceAccount + // discriminator must remain in the static keys. Solana permits trailing instruction data here. + if (TryGetNonceAccount(instructions, out var nonceAccount)) + { + var current = metas[nonceAccount]; + metas[nonceAccount] = current with { IsNonce = true }; + } + if (metas.Count > MaxAccounts) throw new ArgumentException($"A message can reference at most {MaxAccounts} accounts, got {metas.Count}.", nameof(instructions)); @@ -129,7 +163,7 @@ void Merge(PublicKey key, bool signer, bool writable, bool invoked) continue; var meta = metas[key]; - if (meta.IsSigner || meta.IsInvoked || !meta.IsWritable) + if (meta.IsSigner || meta.IsInvoked || meta.IsNonce || !meta.IsWritable) continue; var index = IndexInTable(table.Addresses, key); @@ -147,7 +181,7 @@ void Merge(PublicKey key, bool signer, bool writable, bool invoked) continue; var meta = metas[key]; - if (meta.IsSigner || meta.IsInvoked || meta.IsWritable) + if (meta.IsSigner || meta.IsInvoked || meta.IsNonce || meta.IsWritable) continue; var index = IndexInTable(table.Addresses, key); @@ -183,7 +217,9 @@ void Merge(PublicKey key, bool signer, bool writable, bool invoked) AddClass(orderedStatic, staticRemaining, metas, signer: false, writable: true); AddClass(orderedStatic, staticRemaining, metas, signer: false, writable: false); - byte requiredSignatures = 0, readonlySigned = 0, readonlyUnsigned = 0; + var requiredSignatures = 0; + var readonlySigned = 0; + var readonlyUnsigned = 0; foreach (var key in orderedStatic) { var meta = metas[key]; @@ -199,6 +235,11 @@ void Merge(PublicKey key, bool signer, bool writable, bool invoked) } } + if (requiredSignatures > byte.MaxValue) + throw new ArgumentException( + $"A v0 message can require at most {byte.MaxValue} signatures, got {requiredSignatures}.", + nameof(instructions)); + var position = new Dictionary(metas.Count); var slot = 0; foreach (var key in orderedStatic) @@ -220,11 +261,18 @@ void Merge(PublicKey key, bool signer, bool writable, bool invoked) { ProgramIdIndex = (byte)position[instruction.ProgramId], AccountIndexes = accountIndexes, - Data = instruction.Data + Data = [.. instruction.Data] }; } - return new MessageV0(requiredSignatures, readonlySigned, readonlyUnsigned, orderedStatic, recentBlockhash, compiled, lookups); + return new MessageV0( + (byte)requiredSignatures, + (byte)readonlySigned, + (byte)readonlyUnsigned, + orderedStatic, + recentBlockhash, + compiled, + lookups); } /// Serializes the message to its canonical wire bytes - what a signer signs over - starting with . @@ -242,7 +290,7 @@ public byte[] Serialize() public int GetSerializedLength() { var length = 1 + 3 // the version prefix and the header counts - + ShortVec.GetByteCount(AccountKeys.Count) + AccountKeys.Count * PublicKey.Length + + ShortVec.GetByteCount(AccountKeys.Count) + (AccountKeys.Count * PublicKey.Length) + PublicKey.Length // the recent blockhash + ShortVec.GetByteCount(Instructions.Count); @@ -391,8 +439,8 @@ private static PublicKey AddressAt(AddressLookupTableAccount table, byte index) /// The serialized v0 message. /// The parsed message. /// - /// The data is not a versioned message, carries a version other than 0, is truncated, a compact-u16 - /// length is malformed, or the message breaks a rule Solana's sanitize enforces: header counts that + /// The data is not a versioned message, carries a version other than 0, is truncated, contains trailing + /// bytes, has a malformed compact-u16 length, or breaks a rule Solana's sanitize enforces: header counts that /// overlap the static account list or leave no writable fee-payer signer, an address table lookup that /// loads no accounts, more than 256 addressable accounts, an instruction whose program id is the fee /// payer or a lookup-loaded account, or an out-of-range program id or account index. @@ -406,7 +454,8 @@ public static MessageV0 Deserialize(ReadOnlySpan data) if ((prefix & VersionPrefix) == 0) throw new FormatException("Not a versioned message: the high bit of the version prefix is not set."); - // Only version 0 exists today; a future v1 payload must fail loudly rather than misparse as v0. + // SolSharp currently implements v0; a v1 or later payload must fail loudly rather than + // being misparsed as v0. var version = prefix & ~VersionPrefix; if (version != 0) throw new FormatException($"Unsupported message version {version}; only v0 is supported."); @@ -424,6 +473,11 @@ public static MessageV0 Deserialize(ReadOnlySpan data) var lookupCount = ShortVec.Decode(data[offset..], out var read); offset += read; + const int minimumLookupBytes = PublicKey.Length + 2; // table key plus two zero length prefixes + if ((long)lookupCount * minimumLookupBytes > data.Length - offset) + throw new FormatException( + $"The v0 message declares {lookupCount} address table lookup(s), but the remaining data cannot hold their minimum wire representation."); + var addressTableLookups = new MessageAddressTableLookup[lookupCount]; for (var i = 0; i < lookupCount; i++) { @@ -448,6 +502,9 @@ public static MessageV0 Deserialize(ReadOnlySpan data) }; } + if (offset != data.Length) + throw new FormatException($"The v0 message has {data.Length - offset} trailing byte(s)."); + // Mirror Solana's v0 sanitize so a message the network would refuse never parses successfully. MessageWire.SanitizeHeader(requiredSignatures, readonlySignedAccounts, readonlyUnsignedAccounts, accountKeys.Length); @@ -515,5 +572,24 @@ private static int CompareByBytes(PublicKey a, PublicKey b) return x.SequenceCompareTo(y); } - private readonly record struct KeyMeta(bool IsSigner, bool IsWritable, bool IsInvoked); + private static bool TryGetNonceAccount(IReadOnlyList instructions, out PublicKey nonceAccount) + { + if (instructions.Count > 0) + { + var first = instructions[0]; + if (first.ProgramId == SystemProgram.ProgramId + && first.Data.Length >= sizeof(uint) + && BinaryPrimitives.ReadUInt32LittleEndian(first.Data.AsSpan(0, sizeof(uint))) == 4 + && first.Accounts.Count > 0) + { + nonceAccount = first.Accounts[0].PublicKey; + return true; + } + } + + nonceAccount = default; + return false; + } + + private readonly record struct KeyMeta(bool IsSigner, bool IsWritable, bool IsInvoked, bool IsNonce = false); } diff --git a/src/SolSharp.Programs/MessageV1.cs b/src/SolSharp.Programs/MessageV1.cs new file mode 100644 index 0000000..4d8c203 --- /dev/null +++ b/src/SolSharp.Programs/MessageV1.cs @@ -0,0 +1,683 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// +/// A SIMD-0385 V1 transaction message. V1 stores every account inline, carries compute configuration +/// in its header, and uses fixed-width instruction headers instead of compact-u16 lengths. +/// +public sealed class MessageV1 : ITransactionMessage +{ + /// The leading byte that identifies a V1 message. + public const byte VersionPrefix = 0x81; + + /// + /// The maximum V1 transaction wire size accepted by current RPC and runtime paths. The wire codec + /// intentionally does not enforce this admission limit and can round-trip larger payloads, matching + /// the pinned Solana SDK. + /// + public const int MaxTransactionSize = 4096; + + /// The maximum number of inline account addresses in a V1 message. + public const int MaxAccounts = 64; + + /// The maximum number of instructions in a V1 message. + public const int MaxInstructions = 64; + + /// The maximum number of required signatures in a V1 transaction. + public const int MaxSignatures = 12; + + /// The default V1 transaction heap size when no explicit value is present (32 KiB). + public const uint DefaultHeapSize = MinHeapSize; + + /// The minimum explicit V1 transaction heap size (32 KiB). + public const uint MinHeapSize = 32 * 1024; + + /// The maximum explicit V1 transaction heap size (256 KiB). + public const uint MaxHeapSize = 256 * 1024; + + private const uint PriorityFeeMask = 0b11; + private const uint ComputeUnitLimitMask = 0b100; + private const uint LoadedAccountsDataSizeMask = 0b1000; + private const uint HeapSizeMask = 0b10000; + private const uint KnownConfigMask = 0b11111; + private const int FixedBodyLength = 3 + sizeof(uint) + Hash.Length + 1 + 1; + + private MessageV1( + byte requiredSignatures, + byte readonlySignedAccounts, + byte readonlyUnsignedAccounts, + TransactionConfigV1 config, + Hash lifetimeSpecifier, + IReadOnlyList accountKeys, + IReadOnlyList instructions) + { + RequiredSignatures = requiredSignatures; + ReadonlySignedAccounts = readonlySignedAccounts; + ReadonlyUnsignedAccounts = readonlyUnsignedAccounts; + Config = config; + LifetimeSpecifier = lifetimeSpecifier; + AccountKeys = accountKeys; + Instructions = instructions; + } + + /// + public byte RequiredSignatures { get; } + + /// How many of the signing accounts are read-only. + public byte ReadonlySignedAccounts { get; } + + /// How many of the non-signing accounts are read-only. + public byte ReadonlyUnsignedAccounts { get; } + + /// The inline V1 compute and priority-fee configuration. + public TransactionConfigV1 Config { get; } + + /// The recent blockhash or durable nonce that limits the transaction lifetime. + public Hash LifetimeSpecifier { get; } + + /// + public IReadOnlyList AccountKeys { get; } + + /// + public IReadOnlyList Instructions { get; } + + /// Compiles instructions into a V1 message with an empty inline configuration. + /// The writable signer that pays the transaction fee. + /// The recent blockhash or durable nonce. + /// The instructions to include, in execution order. + /// The compiled V1 message. + /// or an element is null. + /// The instructions cannot be represented by the V1 limits. + /// + /// The empty configuration encodes compute-unit and loaded-account-data limits of zero and normally + /// should be replaced with explicit limits before submitting the transaction. + /// + public static MessageV1 Compile( + PublicKey feePayer, + Hash lifetimeSpecifier, + IReadOnlyList instructions) + => Compile(feePayer, lifetimeSpecifier, instructions, new TransactionConfigV1()); + + /// Compiles instructions into a V1 message with an empty inline configuration. + /// The writable signer that pays the transaction fee. + /// The base58 recent blockhash or durable nonce. + /// The instructions to include, in execution order. + /// The compiled V1 message. + /// + /// , , or an instruction is null. + /// + /// + /// is not a 32-byte base58 hash, or the instructions cannot be + /// represented by the V1 limits. + /// + /// + /// The empty configuration encodes compute-unit and loaded-account-data limits of zero and normally + /// should be replaced with explicit limits before submitting the transaction. + /// + public static MessageV1 Compile( + PublicKey feePayer, + string lifetimeSpecifier, + IReadOnlyList instructions) + { + ArgumentNullException.ThrowIfNull(lifetimeSpecifier); + return Compile(feePayer, new Hash(lifetimeSpecifier), instructions, new TransactionConfigV1()); + } + + /// Compiles instructions and inline configuration into a V1 message. + /// The writable signer that pays the transaction fee. + /// The recent blockhash or durable nonce. + /// The instructions to include, in execution order. + /// The inline compute and priority-fee configuration. + /// The compiled V1 message. + /// + /// , an instruction, or is null. + /// + /// + /// The configuration is invalid or the instructions cannot be represented by the V1 limits. + /// + public static MessageV1 Compile( + PublicKey feePayer, + Hash lifetimeSpecifier, + IReadOnlyList instructions, + TransactionConfigV1 config) + { + ArgumentNullException.ThrowIfNull(instructions); + ArgumentNullException.ThrowIfNull(config); + ValidateConfigForCompile(config); + + if (instructions.Count > MaxInstructions) + throw new ArgumentException( + $"A V1 message can contain at most {MaxInstructions} instructions, got {instructions.Count}.", + nameof(instructions)); + + var flags = new Dictionary(); + + void Merge(PublicKey key, bool signer, bool writable) + { + flags.TryGetValue(key, out var current); + flags[key] = new AccountFlags(current.IsSigner || signer, current.IsWritable || writable); + } + + Merge(feePayer, signer: true, writable: true); + foreach (var instruction in instructions) + { + ArgumentNullException.ThrowIfNull(instruction, nameof(instructions)); + ArgumentNullException.ThrowIfNull(instruction.Accounts, nameof(instructions)); + ArgumentNullException.ThrowIfNull(instruction.Data, nameof(instructions)); + + if (instruction.ProgramId == feePayer) + throw new ArgumentException("The V1 fee payer cannot also be an invoked program.", nameof(instructions)); + if (instruction.Accounts.Count > byte.MaxValue) + throw new ArgumentException( + $"A V1 instruction can reference at most {byte.MaxValue} account slots, got {instruction.Accounts.Count}.", + nameof(instructions)); + if (instruction.Data.Length > ushort.MaxValue) + throw new ArgumentException( + $"A V1 instruction can carry at most {ushort.MaxValue} data bytes, got {instruction.Data.Length}.", + nameof(instructions)); + + foreach (var account in instruction.Accounts) + Merge(account.PublicKey, account.IsSigner, account.IsWritable); + + Merge(instruction.ProgramId, signer: false, writable: false); + } + + if (flags.Count > MaxAccounts) + throw new ArgumentException( + $"A V1 message can reference at most {MaxAccounts} accounts, got {flags.Count}.", + nameof(instructions)); + + var rest = new List(flags.Count); + foreach (var key in flags.Keys) + if (key != feePayer) + rest.Add(key); + + rest.Sort(CompareByBytes); + + var orderedKeys = new List(flags.Count) { feePayer }; + AddClass(orderedKeys, rest, flags, signer: true, writable: true); + AddClass(orderedKeys, rest, flags, signer: true, writable: false); + AddClass(orderedKeys, rest, flags, signer: false, writable: true); + AddClass(orderedKeys, rest, flags, signer: false, writable: false); + + var requiredSignatures = 0; + var readonlySigned = 0; + var readonlyUnsigned = 0; + var positions = new Dictionary(orderedKeys.Count); + for (var index = 0; index < orderedKeys.Count; index++) + { + var key = orderedKeys[index]; + positions[key] = index; + var accountFlags = flags[key]; + if (accountFlags.IsSigner) + { + requiredSignatures++; + if (!accountFlags.IsWritable) + readonlySigned++; + } + else if (!accountFlags.IsWritable) + { + readonlyUnsigned++; + } + } + + if (requiredSignatures > MaxSignatures) + throw new ArgumentException( + $"A V1 message can require at most {MaxSignatures} signatures, got {requiredSignatures}.", + nameof(instructions)); + + var compiled = new CompiledInstruction[instructions.Count]; + for (var index = 0; index < instructions.Count; index++) + { + var instruction = instructions[index]; + var accountIndexes = new byte[instruction.Accounts.Count]; + for (var accountIndex = 0; accountIndex < instruction.Accounts.Count; accountIndex++) + accountIndexes[accountIndex] = (byte)positions[instruction.Accounts[accountIndex].PublicKey]; + + compiled[index] = new CompiledInstruction + { + ProgramIdIndex = (byte)positions[instruction.ProgramId], + AccountIndexes = accountIndexes, + Data = [.. instruction.Data] + }; + } + + return new MessageV1( + (byte)requiredSignatures, + (byte)readonlySigned, + (byte)readonlyUnsigned, + config, + lifetimeSpecifier, + orderedKeys, + compiled); + } + + /// Compiles instructions and inline configuration into a V1 message. + /// The writable signer that pays the transaction fee. + /// The base58 recent blockhash or durable nonce. + /// The instructions to include, in execution order. + /// The inline compute and priority-fee configuration. + /// The compiled V1 message. + /// + /// , , an instruction, or + /// is null. + /// + /// + /// is not a 32-byte base58 hash, the configuration is invalid, + /// or the instructions cannot be represented by the V1 limits. + /// + public static MessageV1 Compile( + PublicKey feePayer, + string lifetimeSpecifier, + IReadOnlyList instructions, + TransactionConfigV1 config) + { + ArgumentNullException.ThrowIfNull(lifetimeSpecifier); + return Compile(feePayer, new Hash(lifetimeSpecifier), instructions, config); + } + + /// Validates all SIMD-0385 header, configuration, account, and instruction constraints. + /// The message violates a V1 sanitize constraint. + public void Validate() + { + if (RequiredSignatures > MaxSignatures) + throw new FormatException($"A V1 message may require at most {MaxSignatures} signatures."); + if (Instructions.Count > MaxInstructions) + throw new FormatException($"A V1 message may contain at most {MaxInstructions} instructions."); + if (AccountKeys.Count > MaxAccounts) + throw new FormatException($"A V1 message may contain at most {MaxAccounts} account addresses."); + if (AccountKeys.Count < RequiredSignatures + ReadonlyUnsignedAccounts) + throw new FormatException( + "The V1 account list cannot satisfy the required-signature and read-only unsigned header counts."); + if (ReadonlySignedAccounts >= RequiredSignatures) + throw new FormatException("A V1 message must have at least one writable signer for the fee payer."); + + var uniqueKeys = new HashSet(); + foreach (var key in AccountKeys) + if (!uniqueKeys.Add(key)) + throw new FormatException("A V1 message cannot contain duplicate account addresses."); + + ValidateConfigForWire(Config); + + foreach (var instruction in Instructions) + { + if (instruction is null) + throw new FormatException("A V1 compiled instruction cannot be null."); + if (instruction.AccountIndexes is null || instruction.Data is null) + throw new FormatException("A V1 compiled instruction must contain account-index and data arrays."); + if (instruction.ProgramIdIndex == 0 || instruction.ProgramIdIndex >= AccountKeys.Count) + throw new FormatException("A V1 instruction program id must reference a non-payer inline account."); + if (instruction.AccountIndexes.Length > byte.MaxValue) + throw new FormatException($"A V1 instruction may reference at most {byte.MaxValue} account slots."); + if (instruction.Data.Length > ushort.MaxValue) + throw new FormatException($"A V1 instruction may contain at most {ushort.MaxValue} data bytes."); + + foreach (var accountIndex in instruction.AccountIndexes) + if (accountIndex >= AccountKeys.Count) + throw new FormatException( + $"V1 instruction account index {accountIndex} is outside {AccountKeys.Count} account addresses."); + } + } + + /// + public byte[] Serialize() + { + var bytes = new byte[GetSerializedLength()]; + Serialize(bytes); + return bytes; + } + + /// + public int GetSerializedLength() + { + var length = 1 + FixedBodyLength + + (AccountKeys.Count * PublicKey.Length) + + GetConfigSerializedLength(Config) + + (Instructions.Count * 4); + + foreach (var instruction in Instructions) + length += instruction.AccountIndexes.Length + instruction.Data.Length; + + return length; + } + + /// + public int Serialize(Span destination) + { + Validate(); + var length = GetSerializedLength(); + if (destination.Length < length) + throw new ArgumentException($"Destination must be at least {length} bytes.", nameof(destination)); + + var offset = 0; + destination[offset++] = VersionPrefix; + destination[offset++] = RequiredSignatures; + destination[offset++] = ReadonlySignedAccounts; + destination[offset++] = ReadonlyUnsignedAccounts; + BinaryPrimitives.WriteUInt32LittleEndian(destination[offset..], GetConfigMask(Config)); + offset += sizeof(uint); + LifetimeSpecifier.CopyTo(destination[offset..]); + offset += Hash.Length; + destination[offset++] = (byte)Instructions.Count; + destination[offset++] = (byte)AccountKeys.Count; + + foreach (var key in AccountKeys) + { + key.CopyTo(destination[offset..]); + offset += PublicKey.Length; + } + + offset += WriteConfig(Config, destination[offset..]); + + foreach (var instruction in Instructions) + { + destination[offset++] = instruction.ProgramIdIndex; + destination[offset++] = (byte)instruction.AccountIndexes.Length; + BinaryPrimitives.WriteUInt16LittleEndian(destination[offset..], (ushort)instruction.Data.Length); + offset += sizeof(ushort); + } + + foreach (var instruction in Instructions) + { + instruction.AccountIndexes.CopyTo(destination[offset..]); + offset += instruction.AccountIndexes.Length; + instruction.Data.CopyTo(destination[offset..]); + offset += instruction.Data.Length; + } + + return offset; + } + + /// Parses one complete version-prefixed V1 message and applies all sanitize checks. + /// The complete V1 message bytes, beginning with . + /// The parsed V1 message. + /// + /// The message is truncated, has trailing data, uses an invalid config mask, or violates a V1 + /// sanitize constraint. + /// + public static MessageV1 Deserialize(ReadOnlySpan data) + => DeserializeCore(data, requireExactLength: true, out _); + + /// + public IReadOnlyList DecompileInstructions(IReadOnlyList lookupTables) + { + ArgumentNullException.ThrowIfNull(lookupTables); + return DecompileInstructions(); + } + + /// + /// Resolves the compiled instructions back into client instructions with their inline account keys and + /// signer/writable flags. V1 has no address lookup tables to resolve. + /// + /// The resolved instructions, in execution order. + /// An instruction contains an account index outside the inline key list. + public IReadOnlyList DecompileInstructions() + => MessageDecompiler.Decompile( + Instructions, + AccountKeys, + RequiredSignatures, + ReadonlySignedAccounts, + ReadonlyUnsignedAccounts, + AccountKeys.Count, + numLoadedWritable: 0); + + internal static MessageV1 DeserializeTransactionMessage(ReadOnlySpan data, out int consumed) + => DeserializeCore(data, requireExactLength: false, out consumed); + + private static MessageV1 DeserializeCore(ReadOnlySpan data, bool requireExactLength, out int consumed) + { + try + { + if (data.Length == 0 || data[0] != VersionPrefix) + throw new FormatException($"A V1 message must begin with version byte 0x{VersionPrefix:X2}."); + if (data.Length < 1 + FixedBodyLength) + throw new FormatException("The V1 message data is truncated in its fixed header."); + + var offset = 1; + var requiredSignatures = data[offset++]; + var readonlySignedAccounts = data[offset++]; + var readonlyUnsignedAccounts = data[offset++]; + var configMask = BinaryPrimitives.ReadUInt32LittleEndian(data[offset..]); + offset += sizeof(uint); + + if ((configMask & ~KnownConfigMask) != 0 || HasPartialPriorityFeeMask(configMask)) + throw new FormatException($"Invalid V1 transaction config mask 0x{configMask:X8}."); + + var lifetimeSpecifier = new Hash(data.Slice(offset, Hash.Length)); + offset += Hash.Length; + var instructionCount = data[offset++]; + var accountCount = data[offset++]; + + if (requiredSignatures > MaxSignatures) + throw new FormatException($"A V1 message may require at most {MaxSignatures} signatures."); + if (instructionCount > MaxInstructions) + throw new FormatException($"A V1 message may contain at most {MaxInstructions} instructions."); + if (accountCount > MaxAccounts) + throw new FormatException($"A V1 message may contain at most {MaxAccounts} account addresses."); + + EnsureRemaining(data, offset, accountCount * PublicKey.Length, "account addresses"); + var accountKeys = new List(accountCount); + for (var index = 0; index < accountCount; index++) + { + accountKeys.Add(new PublicKey(data.Slice(offset, PublicKey.Length))); + offset += PublicKey.Length; + } + + ulong? priorityFee = null; + uint? computeUnitLimit = null; + uint? loadedAccountsDataSizeLimit = null; + uint? heapSize = null; + if ((configMask & PriorityFeeMask) == PriorityFeeMask) + { + EnsureRemaining(data, offset, sizeof(ulong), "priority fee"); + priorityFee = BinaryPrimitives.ReadUInt64LittleEndian(data[offset..]); + offset += sizeof(ulong); + } + + if ((configMask & ComputeUnitLimitMask) != 0) + { + EnsureRemaining(data, offset, sizeof(uint), "compute-unit limit"); + computeUnitLimit = BinaryPrimitives.ReadUInt32LittleEndian(data[offset..]); + offset += sizeof(uint); + } + + if ((configMask & LoadedAccountsDataSizeMask) != 0) + { + EnsureRemaining(data, offset, sizeof(uint), "loaded-account-data limit"); + loadedAccountsDataSizeLimit = BinaryPrimitives.ReadUInt32LittleEndian(data[offset..]); + offset += sizeof(uint); + } + + if ((configMask & HeapSizeMask) != 0) + { + EnsureRemaining(data, offset, sizeof(uint), "heap size"); + heapSize = BinaryPrimitives.ReadUInt32LittleEndian(data[offset..]); + offset += sizeof(uint); + } + + EnsureRemaining(data, offset, instructionCount * 4, "instruction headers"); + var headers = new InstructionHeader[instructionCount]; + long payloadLength = 0; + for (var index = 0; index < instructionCount; index++) + { + var programIdIndex = data[offset++]; + var instructionAccountCount = data[offset++]; + var instructionDataLength = BinaryPrimitives.ReadUInt16LittleEndian(data[offset..]); + offset += sizeof(ushort); + headers[index] = new InstructionHeader( + programIdIndex, + instructionAccountCount, + instructionDataLength); + payloadLength += instructionAccountCount + instructionDataLength; + } + + if (payloadLength > data.Length - offset) + throw new FormatException( + $"The V1 instruction payloads need {payloadLength} byte(s), but only {data.Length - offset} remain."); + + var instructions = new CompiledInstruction[instructionCount]; + for (var index = 0; index < instructionCount; index++) + { + var header = headers[index]; + var accountIndexes = data.Slice(offset, header.AccountCount).ToArray(); + offset += header.AccountCount; + var instructionData = data.Slice(offset, header.DataLength).ToArray(); + offset += header.DataLength; + instructions[index] = new CompiledInstruction + { + ProgramIdIndex = header.ProgramIdIndex, + AccountIndexes = accountIndexes, + Data = instructionData + }; + } + + if (requireExactLength && offset != data.Length) + throw new FormatException($"The V1 message contains {data.Length - offset} trailing byte(s)."); + + var config = new TransactionConfigV1 + { + PriorityFee = priorityFee, + ComputeUnitLimit = computeUnitLimit, + LoadedAccountsDataSizeLimit = loadedAccountsDataSizeLimit, + HeapSize = heapSize + }; + var message = new MessageV1( + requiredSignatures, + readonlySignedAccounts, + readonlyUnsignedAccounts, + config, + lifetimeSpecifier, + accountKeys, + instructions); + message.Validate(); + consumed = offset; + return message; + } + catch (Exception exception) when (exception is IndexOutOfRangeException or ArgumentOutOfRangeException) + { + throw new FormatException("The V1 message data is truncated.", exception); + } + } + + private static uint GetConfigMask(TransactionConfigV1 config) + { + var mask = 0u; + if (config.PriorityFee.HasValue) + mask |= PriorityFeeMask; + if (config.ComputeUnitLimit.HasValue) + mask |= ComputeUnitLimitMask; + if (config.LoadedAccountsDataSizeLimit.HasValue) + mask |= LoadedAccountsDataSizeMask; + if (config.HeapSize.HasValue) + mask |= HeapSizeMask; + return mask; + } + + private static int GetConfigSerializedLength(TransactionConfigV1 config) + { + var length = 0; + if (config.PriorityFee.HasValue) + length += sizeof(ulong); + if (config.ComputeUnitLimit.HasValue) + length += sizeof(uint); + if (config.LoadedAccountsDataSizeLimit.HasValue) + length += sizeof(uint); + if (config.HeapSize.HasValue) + length += sizeof(uint); + return length; + } + + private static int WriteConfig(TransactionConfigV1 config, Span destination) + { + var offset = 0; + if (config.PriorityFee is { } priorityFee) + { + BinaryPrimitives.WriteUInt64LittleEndian(destination[offset..], priorityFee); + offset += sizeof(ulong); + } + + if (config.ComputeUnitLimit is { } computeUnitLimit) + { + BinaryPrimitives.WriteUInt32LittleEndian(destination[offset..], computeUnitLimit); + offset += sizeof(uint); + } + + if (config.LoadedAccountsDataSizeLimit is { } loadedAccountsDataSizeLimit) + { + BinaryPrimitives.WriteUInt32LittleEndian(destination[offset..], loadedAccountsDataSizeLimit); + offset += sizeof(uint); + } + + if (config.HeapSize is { } heapSize) + { + BinaryPrimitives.WriteUInt32LittleEndian(destination[offset..], heapSize); + offset += sizeof(uint); + } + + return offset; + } + + private static bool HasPartialPriorityFeeMask(uint mask) + { + var priorityBits = mask & PriorityFeeMask; + return priorityBits is not 0 and not PriorityFeeMask; + } + + private static void ValidateConfigForCompile(TransactionConfigV1 config) + { + if (config.HeapSize is { } heapSize + && (heapSize < MinHeapSize || heapSize > MaxHeapSize || heapSize % 1024 != 0)) + { + throw new ArgumentException( + $"A V1 heap size must be a 1024-byte multiple from {MinHeapSize} through {MaxHeapSize}.", + nameof(config)); + } + } + + private static void ValidateConfigForWire(TransactionConfigV1 config) + { + if (config.HeapSize is { } heapSize + && (heapSize < MinHeapSize || heapSize > MaxHeapSize || heapSize % 1024 != 0)) + { + throw new FormatException( + $"A V1 heap size must be a 1024-byte multiple from {MinHeapSize} through {MaxHeapSize}."); + } + } + + private static void EnsureRemaining(ReadOnlySpan data, int offset, int needed, string field) + { + if (needed > data.Length - offset) + throw new FormatException( + $"The V1 message is truncated in {field}: need {needed} byte(s), but only {data.Length - offset} remain."); + } + + private static void AddClass( + List target, + IReadOnlyList sortedRest, + IReadOnlyDictionary flags, + bool signer, + bool writable) + { + foreach (var key in sortedRest) + { + var accountFlags = flags[key]; + if (accountFlags.IsSigner == signer && accountFlags.IsWritable == writable) + target.Add(key); + } + } + + private static int CompareByBytes(PublicKey left, PublicKey right) + { + Span leftBytes = stackalloc byte[PublicKey.Length]; + Span rightBytes = stackalloc byte[PublicKey.Length]; + left.CopyTo(leftBytes); + right.CopyTo(rightBytes); + return leftBytes.SequenceCompareTo(rightBytes); + } + + private readonly record struct AccountFlags(bool IsSigner, bool IsWritable); + + private readonly record struct InstructionHeader(byte ProgramIdIndex, byte AccountCount, ushort DataLength); +} diff --git a/src/SolSharp.Programs/MessageWire.cs b/src/SolSharp.Programs/MessageWire.cs index 493673b..3e78f32 100644 --- a/src/SolSharp.Programs/MessageWire.cs +++ b/src/SolSharp.Programs/MessageWire.cs @@ -14,6 +14,7 @@ public static PublicKey[] ReadAccountKeys(ReadOnlySpan data, ref int offse { var count = ShortVec.Decode(data[offset..], out var read); offset += read; + EnsureMinimumBytes(data.Length - offset, count, PublicKey.Length, "account key"); var keys = new PublicKey[count]; for (var i = 0; i < count; i++) @@ -30,6 +31,9 @@ public static CompiledInstruction[] ReadInstructions(ReadOnlySpan data, re { var count = ShortVec.Decode(data[offset..], out var read); offset += read; + // Even an instruction with no accounts and no data needs a program-id byte and two + // one-byte zero length prefixes. Reject impossible declared counts before allocating. + EnsureMinimumBytes(data.Length - offset, count, 3, "instruction"); var instructions = new CompiledInstruction[count]; for (var i = 0; i < count; i++) @@ -57,6 +61,14 @@ public static CompiledInstruction[] ReadInstructions(ReadOnlySpan data, re return instructions; } + private static void EnsureMinimumBytes(int remainingBytes, int count, int minimumElementBytes, string elementName) + { + var minimumBytes = (long)count * minimumElementBytes; + if (minimumBytes > remainingBytes) + throw new FormatException( + $"The wire data declares {count} {elementName}(s), which need at least {minimumBytes} byte(s), but only {remainingBytes} byte(s) remain."); + } + /// /// Enforces the header rules of Solana's sanitize: the signing area and the read-only non-signing area /// must fit the account list without overlapping, and at least one signer must be writable so it can diff --git a/src/SolSharp.Programs/PrecompileSignatureOffsets.cs b/src/SolSharp.Programs/PrecompileSignatureOffsets.cs new file mode 100644 index 0000000..e7d6488 --- /dev/null +++ b/src/SolSharp.Programs/PrecompileSignatureOffsets.cs @@ -0,0 +1,52 @@ +namespace SolSharp.Programs; + +/// One 14-byte Ed25519 precompile offsets record. +/// Offset to the 64-byte signature. +/// Instruction containing the signature. +/// Offset to the 32-byte public key. +/// Instruction containing the public key. +/// Offset to the message. +/// Message length in bytes. +/// Instruction containing the message. +public readonly record struct Ed25519SignatureOffsets( + ushort SignatureOffset, + ushort SignatureInstructionIndex, + ushort PublicKeyOffset, + ushort PublicKeyInstructionIndex, + ushort MessageOffset, + ushort MessageLength, + ushort MessageInstructionIndex); + +/// One 14-byte Secp256r1 precompile offsets record. +/// Offset to the 64-byte compact signature. +/// Instruction containing the signature. +/// Offset to the 33-byte compressed public key. +/// Instruction containing the public key. +/// Offset to the message. +/// Message length in bytes. +/// Instruction containing the message. +public readonly record struct Secp256r1SignatureOffsets( + ushort SignatureOffset, + ushort SignatureInstructionIndex, + ushort PublicKeyOffset, + ushort PublicKeyInstructionIndex, + ushort MessageOffset, + ushort MessageLength, + ushort MessageInstructionIndex); + +/// One 11-byte Secp256k1 precompile offsets record. +/// Offset to the 64-byte signature followed by its recovery ID. +/// Instruction containing the signature. +/// Offset to the 20-byte Ethereum address. +/// Instruction containing the address. +/// Offset to the message. +/// Message length in bytes. +/// Instruction containing the message. +public readonly record struct Secp256k1SignatureOffsets( + ushort SignatureOffset, + byte SignatureInstructionIndex, + ushort EthereumAddressOffset, + byte EthereumAddressInstructionIndex, + ushort MessageOffset, + ushort MessageLength, + byte MessageInstructionIndex); diff --git a/src/SolSharp.Programs/ProgramDerivedAddress.cs b/src/SolSharp.Programs/ProgramDerivedAddress.cs index 755b8c4..703ca89 100644 --- a/src/SolSharp.Programs/ProgramDerivedAddress.cs +++ b/src/SolSharp.Programs/ProgramDerivedAddress.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Text; using SolSharp.Core.Primitives; using SolSharp.Wallet; @@ -16,8 +17,58 @@ public static class ProgramDerivedAddress /// The maximum number of seeds a PDA derivation accepts (16). The bump seed counts toward the limit. public const int MaxSeeds = 16; + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + private static ReadOnlySpan Marker => "ProgramDerivedAddress"u8; + /// + /// Derives an address for a base account, UTF-8 seed, and owner using Solana's + /// create_with_seed SHA-256 construction. Unlike a PDA, the result may be on the curve. + /// + /// The base account whose authority can create the derived account. + /// The UTF-8 seed, at most encoded bytes. + /// The program that will own the derived account. + /// The SHA-256 hash of the base address, UTF-8 seed bytes, and owner address. + /// is null. + /// + /// is invalid UTF-16 or exceeds UTF-8 bytes, or the + /// owner bytes end with Solana's reserved ProgramDerivedAddress marker. + /// + public static PublicKey CreateWithSeed(PublicKey baseAddress, string seed, PublicKey owner) + { + ArgumentNullException.ThrowIfNull(seed); + + int seedLength; + try + { + seedLength = StrictUtf8.GetByteCount(seed); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException("The derived-address seed must contain valid UTF-16 text.", nameof(seed), exception); + } + + if (seedLength > MaxSeedLength) + throw new ArgumentException( + $"A derived-address seed may be at most {MaxSeedLength} UTF-8 bytes, got {seedLength}.", + nameof(seed)); + + Span input = stackalloc byte[PublicKey.Length + seedLength + PublicKey.Length]; + baseAddress.CopyTo(input); + StrictUtf8.GetBytes(seed, input.Slice(PublicKey.Length, seedLength)); + + var ownerBytes = input[(PublicKey.Length + seedLength)..]; + owner.CopyTo(ownerBytes); + if (ownerBytes.EndsWith(Marker)) + throw new ArgumentException( + "The owner address ends with Solana's reserved ProgramDerivedAddress marker.", + nameof(owner)); + + Span hash = stackalloc byte[PublicKey.Length]; + SHA256.HashData(input, hash); + return new PublicKey(hash); + } + /// /// Derives the canonical PDA for under , trying bump /// seeds from 255 downward and returning the first that produces an off-curve address. @@ -25,16 +76,25 @@ public static class ProgramDerivedAddress /// The seeds; each may be at most bytes, and at most - 1 of them (the bump occupies the last slot). /// The program the address is derived for. /// The derived address and the bump seed that produced it. - /// is null. + /// or one of its elements is null. /// A seed exceeds bytes, or the seeds plus the bump exceed . /// No bump seed produced an off-curve address (cryptographically improbable). public static (PublicKey Address, byte Bump) FindProgramAddress(IReadOnlyList seeds, PublicKey programId) { ArgumentNullException.ThrowIfNull(seeds); + var seedCount = seeds.Count; + if (seedCount >= MaxSeeds) + throw new ArgumentException( + $"Finding a PDA accepts at most {MaxSeeds - 1} caller seed(s), got {seedCount}; the bump occupies the final slot.", + nameof(seeds)); - var withBump = new byte[seeds.Count + 1][]; - for (var i = 0; i < seeds.Count; i++) - withBump[i] = seeds[i]; + var withBump = new byte[seedCount + 1][]; + for (var i = 0; i < seedCount; i++) + { + var seed = seeds[i]; + ArgumentNullException.ThrowIfNull(seed, nameof(seeds)); + withBump[i] = seed; + } for (var bump = 255; bump >= 0; bump--) { @@ -54,7 +114,7 @@ public static (PublicKey Address, byte Bump) FindProgramAddress(IReadOnlyListThe program the address is derived for. /// The derived off-curve address on success; otherwise. /// true if the seeds produced a valid off-curve address. - /// is null. + /// or one of its elements is null. /// More than seeds, or a seed exceeds bytes. public static bool TryCreateProgramAddress(IReadOnlyList seeds, PublicKey programId, out PublicKey address) { @@ -68,6 +128,7 @@ public static bool TryCreateProgramAddress(IReadOnlyList seeds, PublicKe using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); foreach (var seed in seeds) { + ArgumentNullException.ThrowIfNull(seed, nameof(seeds)); if (seed.Length > MaxSeedLength) throw new ArgumentException($"A PDA seed may be at most {MaxSeedLength} bytes, got {seed.Length}.", nameof(seeds)); diff --git a/src/SolSharp.Programs/ProgramWireEncoding.cs b/src/SolSharp.Programs/ProgramWireEncoding.cs new file mode 100644 index 0000000..4c105c9 --- /dev/null +++ b/src/SolSharp.Programs/ProgramWireEncoding.cs @@ -0,0 +1,137 @@ +using System.Buffers.Binary; +using System.Text; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +internal static class ProgramWireEncoding +{ + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + public static byte[] Build(uint discriminator, Action? writePayload = null) + { + using var stream = new MemoryStream(); + WriteUInt32(stream, discriminator); + writePayload?.Invoke(stream); + return stream.ToArray(); + } + + public static void WriteByte(MemoryStream stream, byte value) => stream.WriteByte(value); + + public static void WriteUInt16(MemoryStream stream, ushort value) + { + Span bytes = stackalloc byte[sizeof(ushort)]; + BinaryPrimitives.WriteUInt16LittleEndian(bytes, value); + stream.Write(bytes); + } + + public static void WriteUInt32(MemoryStream stream, uint value) + { + Span bytes = stackalloc byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, value); + stream.Write(bytes); + } + + public static void WriteUInt64(MemoryStream stream, ulong value) + { + Span bytes = stackalloc byte[sizeof(ulong)]; + BinaryPrimitives.WriteUInt64LittleEndian(bytes, value); + stream.Write(bytes); + } + + public static void WriteInt64(MemoryStream stream, long value) + { + Span bytes = stackalloc byte[sizeof(long)]; + BinaryPrimitives.WriteInt64LittleEndian(bytes, value); + stream.Write(bytes); + } + + public static void WritePublicKey(MemoryStream stream, PublicKey publicKey) + { + Span bytes = stackalloc byte[PublicKey.Length]; + publicKey.CopyTo(bytes); + stream.Write(bytes); + } + + public static void WriteHash(MemoryStream stream, Hash hash) + { + Span bytes = stackalloc byte[Hash.Length]; + hash.CopyTo(bytes); + stream.Write(bytes); + } + + public static void WriteByteVector(MemoryStream stream, ReadOnlySpan bytes) + { + WriteUInt64(stream, checked((ulong)bytes.Length)); + stream.Write(bytes); + } + + public static void WriteString(MemoryStream stream, string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + + byte[] bytes; + try + { + bytes = StrictUtf8.GetBytes(value); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException("The value must contain valid Unicode text.", parameterName, exception); + } + + WriteUInt64(stream, checked((ulong)bytes.Length)); + stream.Write(bytes); + } + + public static void WriteOptionalInt64(MemoryStream stream, long? value) + { + WriteByte(stream, value.HasValue ? (byte)1 : (byte)0); + if (value.HasValue) + WriteInt64(stream, value.Value); + } + + public static void WriteOptionalUInt64(MemoryStream stream, ulong? value) + { + WriteByte(stream, value.HasValue ? (byte)1 : (byte)0); + if (value.HasValue) + WriteUInt64(stream, value.Value); + } + + public static void WriteOptionalPublicKey(MemoryStream stream, PublicKey? value) + { + WriteByte(stream, value.HasValue ? (byte)1 : (byte)0); + if (value.HasValue) + WritePublicKey(stream, value.Value); + } + + public static void WriteShortVectorLength(MemoryStream stream, int length) + { + if (length is < 0 or > ushort.MaxValue) + throw new ArgumentOutOfRangeException(nameof(length), length, "A Solana short vector may contain at most 65,535 elements."); + + var remaining = (uint)length; + do + { + var next = (byte)(remaining & 0x7f); + remaining >>= 7; + if (remaining is not 0) + next |= 0x80; + stream.WriteByte(next); + } + while (remaining is not 0); + } + + public static void WriteUnsignedLeb128(MemoryStream stream, ulong value) + { + do + { + var next = (byte)(value & 0x7f); + value >>= 7; + if (value != 0) + next |= 0x80; + stream.WriteByte(next); + } + while (value != 0); + } +} diff --git a/src/SolSharp.Programs/Secp256k1Program.cs b/src/SolSharp.Programs/Secp256k1Program.cs new file mode 100644 index 0000000..f0c534f --- /dev/null +++ b/src/SolSharp.Programs/Secp256k1Program.cs @@ -0,0 +1,137 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Builds and decodes Ethereum-compatible Secp256k1 native verification instructions. +public static class Secp256k1Program +{ + /// The Ethereum address length. + public const int EthereumAddressLength = 20; + + /// The compact Secp256k1 signature length, excluding recovery ID. + public const int SignatureLength = 64; + + /// The serialized length of one offsets record. + public const int SignatureOffsetsLength = 11; + + /// The start offset for a one-signature self-contained instruction's payload. + public const int DataStart = 12; + + /// The Secp256k1 native precompile address. + public static readonly PublicKey ProgramId = + PublicKey.Parse("KeccakSecp256k11111111111111111111111111111"); + + /// Builds a self-contained verification instruction for one precomputed signature. + /// The signed message; the precompile hashes it with Keccak-256. + /// The 64-byte compact Secp256k1 signature. + /// The signature recovery ID. + /// The 20-byte Ethereum address. + /// The account-free precompile instruction. + public static Instruction CreateInstruction( + ReadOnlySpan message, + ReadOnlySpan signature, + byte recoveryId, + ReadOnlySpan ethereumAddress) + { + ValidateLength(signature, SignatureLength, nameof(signature)); + ValidateLength(ethereumAddress, EthereumAddressLength, nameof(ethereumAddress)); + ValidateMessageLength(message); + + const ushort addressOffset = DataStart; + const ushort signatureOffset = DataStart + EthereumAddressLength; + const ushort messageOffset = DataStart + EthereumAddressLength + SignatureLength + 1; + var data = new byte[messageOffset + message.Length]; + data[0] = 1; + WriteOffsets( + data.AsSpan(1), + new Secp256k1SignatureOffsets( + signatureOffset, + 0, + addressOffset, + 0, + messageOffset, + (ushort)message.Length, + 0)); + ethereumAddress.CopyTo(data.AsSpan(addressOffset, EthereumAddressLength)); + signature.CopyTo(data.AsSpan(signatureOffset, SignatureLength)); + data[signatureOffset + SignatureLength] = recoveryId; + message.CopyTo(data.AsSpan(messageOffset)); + return new Instruction { ProgramId = ProgramId, Accounts = [], Data = data }; + } + + /// Builds an offsets-only instruction for data stored in this or other instructions. + /// The offsets records. + /// The account-free precompile instruction. + public static Instruction CreateOffsetsInstruction(IReadOnlyList offsets) + { + ArgumentNullException.ThrowIfNull(offsets); + if (offsets.Count > byte.MaxValue) + throw new ArgumentException("At most 255 offsets may be encoded.", nameof(offsets)); + + var data = new byte[checked(1 + (offsets.Count * SignatureOffsetsLength))]; + data[0] = (byte)offsets.Count; + for (var i = 0; i < offsets.Count; i++) + WriteOffsets(data.AsSpan(1 + (i * SignatureOffsetsLength)), offsets[i]); + return new Instruction { ProgramId = ProgramId, Accounts = [], Data = data }; + } + + /// Decodes the offsets table at the start of Secp256k1 instruction data. + /// The complete instruction data. + /// The decoded records; appended signature data is ignored. + /// The header or offsets table is truncated. + public static Secp256k1SignatureOffsets[] DecodeOffsets(ReadOnlySpan data) + { + if (data.IsEmpty) + throw new ArgumentException("Secp256k1 instruction data requires a count byte.", nameof(data)); + + var count = data[0]; + if (count == 0 && data.Length > 1) + throw new ArgumentException("A zero-count Secp256k1 instruction cannot contain trailing data.", nameof(data)); + var tableLength = 1 + (count * SignatureOffsetsLength); + if (data.Length < tableLength) + throw new ArgumentException("Secp256k1 instruction data contains a truncated offsets table.", nameof(data)); + + var offsets = new Secp256k1SignatureOffsets[count]; + for (var i = 0; i < offsets.Length; i++) + { + var record = data[(1 + (i * SignatureOffsetsLength))..]; + offsets[i] = new Secp256k1SignatureOffsets( + ReadUInt16(record, 0), + record[2], + ReadUInt16(record, 3), + record[5], + ReadUInt16(record, 6), + ReadUInt16(record, 8), + record[10]); + } + + return offsets; + } + + private static void WriteOffsets(Span destination, Secp256k1SignatureOffsets offsets) + { + BinaryPrimitives.WriteUInt16LittleEndian(destination, offsets.SignatureOffset); + destination[2] = offsets.SignatureInstructionIndex; + BinaryPrimitives.WriteUInt16LittleEndian(destination[3..], offsets.EthereumAddressOffset); + destination[5] = offsets.EthereumAddressInstructionIndex; + BinaryPrimitives.WriteUInt16LittleEndian(destination[6..], offsets.MessageOffset); + BinaryPrimitives.WriteUInt16LittleEndian(destination[8..], offsets.MessageLength); + destination[10] = offsets.MessageInstructionIndex; + } + + private static ushort ReadUInt16(ReadOnlySpan data, int offset) + => BinaryPrimitives.ReadUInt16LittleEndian(data[offset..]); + + private static void ValidateLength(ReadOnlySpan value, int expected, string parameterName) + { + if (value.Length != expected) + throw new ArgumentException($"Value must be exactly {expected} bytes, got {value.Length}.", parameterName); + } + + private static void ValidateMessageLength(ReadOnlySpan message) + { + if (message.Length > ushort.MaxValue) + throw new ArgumentException("A precompile message may contain at most 65,535 bytes.", nameof(message)); + } +} diff --git a/src/SolSharp.Programs/Secp256r1Program.cs b/src/SolSharp.Programs/Secp256r1Program.cs new file mode 100644 index 0000000..4944619 --- /dev/null +++ b/src/SolSharp.Programs/Secp256r1Program.cs @@ -0,0 +1,135 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Builds and decodes Secp256r1 native signature-verification instructions. +public static class Secp256r1Program +{ + /// The compressed Secp256r1 public-key length. + public const int CompressedPublicKeyLength = 33; + + /// The compact Secp256r1 signature length. + public const int SignatureLength = 64; + + /// The serialized length of one offsets record. + public const int SignatureOffsetsLength = 14; + + /// The start offset for a self-contained instruction's payload. + public const int DataStart = 16; + + /// The Secp256r1 native precompile address. + public static readonly PublicKey ProgramId = + PublicKey.Parse("Secp256r1SigVerify1111111111111111111111111"); + + /// Builds a self-contained verification instruction for one precomputed signature. + /// The signed message; the precompile hashes it with SHA-256. + /// The 64-byte compact, low-S signature. + /// The 33-byte compressed public key. + /// The account-free precompile instruction. + public static Instruction CreateInstruction( + ReadOnlySpan message, + ReadOnlySpan signature, + ReadOnlySpan compressedPublicKey) + { + ValidateLength(signature, SignatureLength, nameof(signature)); + ValidateLength(compressedPublicKey, CompressedPublicKeyLength, nameof(compressedPublicKey)); + ValidateMessageLength(message); + + const ushort publicKeyOffset = DataStart; + const ushort signatureOffset = DataStart + CompressedPublicKeyLength; + const ushort messageOffset = DataStart + CompressedPublicKeyLength + SignatureLength; + var data = new byte[messageOffset + message.Length]; + BinaryPrimitives.WriteUInt16LittleEndian(data, 1); + WriteOffsets( + data.AsSpan(2), + new Secp256r1SignatureOffsets( + signatureOffset, + ushort.MaxValue, + publicKeyOffset, + ushort.MaxValue, + messageOffset, + (ushort)message.Length, + ushort.MaxValue)); + compressedPublicKey.CopyTo(data.AsSpan(publicKeyOffset, CompressedPublicKeyLength)); + signature.CopyTo(data.AsSpan(signatureOffset, SignatureLength)); + message.CopyTo(data.AsSpan(messageOffset)); + return new Instruction { ProgramId = ProgramId, Accounts = [], Data = data }; + } + + /// Builds an offsets-only instruction for data stored in this or other instructions. + /// The offsets records. + /// The account-free precompile instruction. + /// The record count is outside the runtime-supported range 1-8. + public static Instruction CreateOffsetsInstruction(IReadOnlyList offsets) + { + ArgumentNullException.ThrowIfNull(offsets); + if (offsets.Count is < 1 or > 8) + throw new ArgumentException("The Secp256r1 precompile accepts between 1 and 8 offset records.", nameof(offsets)); + + var data = new byte[checked(2 + (offsets.Count * SignatureOffsetsLength))]; + data[0] = (byte)offsets.Count; + for (var i = 0; i < offsets.Count; i++) + WriteOffsets(data.AsSpan(2 + (i * SignatureOffsetsLength)), offsets[i]); + return new Instruction { ProgramId = ProgramId, Accounts = [], Data = data }; + } + + /// Decodes the offsets table at the start of Secp256r1 instruction data. + /// The complete instruction data. + /// The decoded records; appended signature data is ignored. + /// The header or offsets table is truncated. + public static Secp256r1SignatureOffsets[] DecodeOffsets(ReadOnlySpan data) + { + if (data.Length < 2) + throw new ArgumentException("Secp256r1 instruction data requires a two-byte count.", nameof(data)); + + var count = data[0]; + if (count is < 1 or > 8) + throw new ArgumentException("A Secp256r1 instruction must contain between 1 and 8 offset records.", nameof(data)); + var tableLength = checked(2 + (count * SignatureOffsetsLength)); + if (data.Length < tableLength) + throw new ArgumentException("Secp256r1 instruction data contains a truncated offsets table.", nameof(data)); + + var offsets = new Secp256r1SignatureOffsets[count]; + for (var i = 0; i < offsets.Length; i++) + { + var record = data[(2 + (i * SignatureOffsetsLength))..]; + offsets[i] = new Secp256r1SignatureOffsets( + ReadUInt16(record, 0), + ReadUInt16(record, 2), + ReadUInt16(record, 4), + ReadUInt16(record, 6), + ReadUInt16(record, 8), + ReadUInt16(record, 10), + ReadUInt16(record, 12)); + } + + return offsets; + } + + private static void WriteOffsets(Span destination, Secp256r1SignatureOffsets offsets) + { + BinaryPrimitives.WriteUInt16LittleEndian(destination, offsets.SignatureOffset); + BinaryPrimitives.WriteUInt16LittleEndian(destination[2..], offsets.SignatureInstructionIndex); + BinaryPrimitives.WriteUInt16LittleEndian(destination[4..], offsets.PublicKeyOffset); + BinaryPrimitives.WriteUInt16LittleEndian(destination[6..], offsets.PublicKeyInstructionIndex); + BinaryPrimitives.WriteUInt16LittleEndian(destination[8..], offsets.MessageOffset); + BinaryPrimitives.WriteUInt16LittleEndian(destination[10..], offsets.MessageLength); + BinaryPrimitives.WriteUInt16LittleEndian(destination[12..], offsets.MessageInstructionIndex); + } + + private static ushort ReadUInt16(ReadOnlySpan data, int offset) + => BinaryPrimitives.ReadUInt16LittleEndian(data[offset..]); + + private static void ValidateLength(ReadOnlySpan value, int expected, string parameterName) + { + if (value.Length != expected) + throw new ArgumentException($"Value must be exactly {expected} bytes, got {value.Length}.", parameterName); + } + + private static void ValidateMessageLength(ReadOnlySpan message) + { + if (message.Length > ushort.MaxValue) + throw new ArgumentException("A precompile message may contain at most 65,535 bytes.", nameof(message)); + } +} diff --git a/src/SolSharp.Programs/SolSharp.Programs.csproj b/src/SolSharp.Programs/SolSharp.Programs.csproj index 743c86e..c82172d 100644 --- a/src/SolSharp.Programs/SolSharp.Programs.csproj +++ b/src/SolSharp.Programs/SolSharp.Programs.csproj @@ -3,7 +3,7 @@ net8.0 true - Solana transaction building for .NET: instructions, legacy message compilation and wire serialization, transaction signing, and System / Compute Budget / SPL Token instruction builders. + Solana client programs and transaction wire formats for .NET: bounded legacy/v0/V1 compilation, signing, serialization and decoding; current native-program, SPL Token, Token-2022, ATA and ALT instruction builders and state decoders. diff --git a/src/SolSharp.Programs/SplInterfaceInstructionDecoder.cs b/src/SolSharp.Programs/SplInterfaceInstructionDecoder.cs new file mode 100644 index 0000000..1eb68bb --- /dev/null +++ b/src/SolSharp.Programs/SplInterfaceInstructionDecoder.cs @@ -0,0 +1,70 @@ +namespace SolSharp.Programs; + +/// A decoded discriminator and opaque payload from an SPL interface instruction. +public sealed class DecodedSplInterfaceInstruction +{ + private readonly byte[] _payload; + + internal DecodedSplInterfaceInstruction(string name, ReadOnlySpan payload) + { + Name = name; + _payload = payload.ToArray(); + } + + /// The upstream instruction variant name. + public string Name { get; } + + /// The exact bytes after the interface discriminator. + public ReadOnlyMemory Payload => _payload; +} + +public static partial class Token2022Program +{ + /// Decodes an SPL token-metadata interface instruction discriminator. + /// Complete instruction data. + /// The variant and raw Borsh payload, or null for an unknown/short discriminator. + public static DecodedSplInterfaceInstruction? DecodeTokenMetadataInstructionData(ReadOnlySpan data) + { + if (data.Length < 8) + return null; + var discriminator = data[..8]; + var name = discriminator.SequenceEqual(InitializeMetadataDiscriminator) ? "Initialize" : + discriminator.SequenceEqual(UpdateMetadataFieldDiscriminator) ? "UpdateField" : + discriminator.SequenceEqual(RemoveMetadataKeyDiscriminator) ? "RemoveKey" : + discriminator.SequenceEqual(UpdateMetadataAuthorityDiscriminator) ? "UpdateAuthority" : + discriminator.SequenceEqual(EmitMetadataDiscriminator) ? "Emit" : null; + return name is null ? null : new DecodedSplInterfaceInstruction(name, data[8..]); + } + + /// Decodes an SPL token-group interface instruction discriminator. + /// Complete instruction data. + /// The variant and raw POD payload, or null for an unknown/short discriminator. + public static DecodedSplInterfaceInstruction? DecodeTokenGroupInstructionData(ReadOnlySpan data) + { + if (data.Length < 8) + return null; + var discriminator = data[..8]; + var name = discriminator.SequenceEqual(InitializeTokenGroupDiscriminator) ? "InitializeGroup" : + discriminator.SequenceEqual(UpdateTokenGroupMaxSizeDiscriminator) ? "UpdateGroupMaxSize" : + discriminator.SequenceEqual(UpdateTokenGroupAuthorityDiscriminator) ? "UpdateGroupAuthority" : + discriminator.SequenceEqual(InitializeTokenGroupMemberDiscriminator) ? "InitializeMember" : null; + return name is null ? null : new DecodedSplInterfaceInstruction(name, data[8..]); + } +} + +public static partial class TransferHookProgram +{ + /// Decodes an SPL transfer-hook interface instruction discriminator. + /// Complete instruction data. + /// The variant and raw POD payload, or null for an unknown/short discriminator. + public static DecodedSplInterfaceInstruction? DecodeInstructionData(ReadOnlySpan data) + { + if (data.Length < 8) + return null; + var discriminator = data[..8]; + var name = discriminator.SequenceEqual(ExecuteDiscriminator) ? "Execute" : + discriminator.SequenceEqual(InitializeExtraAccountMetasDiscriminator) ? "InitializeExtraAccountMetaList" : + discriminator.SequenceEqual(UpdateExtraAccountMetasDiscriminator) ? "UpdateExtraAccountMetaList" : null; + return name is null ? null : new DecodedSplInterfaceInstruction(name, data[8..]); + } +} diff --git a/src/SolSharp.Programs/StakeAccountState.cs b/src/SolSharp.Programs/StakeAccountState.cs new file mode 100644 index 0000000..28fe7d9 --- /dev/null +++ b/src/SolSharp.Programs/StakeAccountState.cs @@ -0,0 +1,114 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Decoded contents of a fixed-size Solana stake account (StakeStateV2). +public sealed class StakeAccountState +{ + /// The fixed serialized length of every stake account. + public const int AccountDataLength = 200; + + private StakeAccountState( + StakeAccountStateKind kind, + StakeAccountMetadata? metadata, + StakeDelegatedData? stake, + byte stakeFlags) + { + Kind = kind; + Metadata = metadata; + Stake = stake; + StakeFlags = stakeFlags; + } + + /// The decoded state variant. + public StakeAccountStateKind Kind { get; } + + /// The account metadata for initialized and delegated states. + public StakeAccountMetadata? Metadata { get; } + + /// The delegation for the delegated state. + public StakeDelegatedData? Stake { get; } + + /// The raw StakeFlags byte for the delegated state. + public byte StakeFlags { get; } + + /// Decodes exactly 200 bytes of bincode-compatible StakeStateV2 account data. + /// The stake account data. + /// The decoded state. + /// is not exactly 200 bytes. + /// The state discriminator is unknown. + public static StakeAccountState Parse(ReadOnlySpan data) + { + if (data.Length != AccountDataLength) + { + throw new ArgumentException( + $"Stake account data must be exactly {AccountDataLength} bytes, got {data.Length}.", + nameof(data)); + } + + var kindValue = BinaryPrimitives.ReadUInt32LittleEndian(data); + if (!Enum.IsDefined(typeof(StakeAccountStateKind), kindValue)) + throw new FormatException($"Unknown stake-account state discriminator {kindValue}."); + + var kind = (StakeAccountStateKind)kindValue; + if (kind is StakeAccountStateKind.Uninitialized or StakeAccountStateKind.RewardsPool) + return new StakeAccountState(kind, null, null, 0); + + var metadata = ReadMetadata(data[sizeof(uint)..]); + if (kind == StakeAccountStateKind.Initialized) + return new StakeAccountState(kind, metadata, null, 0); + + const int stakeOffset = sizeof(uint) + 120; + var voter = new PublicKey(data.Slice(stakeOffset, PublicKey.Length)); + var lamports = BinaryPrimitives.ReadUInt64LittleEndian(data[(stakeOffset + 32)..]); + var activationEpoch = BinaryPrimitives.ReadUInt64LittleEndian(data[(stakeOffset + 40)..]); + var deactivationEpoch = BinaryPrimitives.ReadUInt64LittleEndian(data[(stakeOffset + 48)..]); + var reserved = BinaryPrimitives.ReadUInt64LittleEndian(data[(stakeOffset + 56)..]); + var creditsObserved = BinaryPrimitives.ReadUInt64LittleEndian(data[(stakeOffset + 64)..]); + var delegation = new StakeDelegation(voter, lamports, activationEpoch, deactivationEpoch, reserved); + + return new StakeAccountState( + kind, + metadata, + new StakeDelegatedData(delegation, creditsObserved), + data[stakeOffset + 72]); + } + + /// Attempts to decode a fixed-size StakeStateV2 value. + /// The stake account data. + /// The decoded state on success; otherwise null. + /// true when the input has the expected length and a known discriminator. + public static bool TryParse(ReadOnlySpan data, out StakeAccountState? state) + { + try + { + state = Parse(data); + return true; + } + catch (ArgumentException) + { + state = null; + return false; + } + catch (FormatException) + { + state = null; + return false; + } + } + + private static StakeAccountMetadata ReadMetadata(ReadOnlySpan data) + { + var rentExemptReserve = BinaryPrimitives.ReadUInt64LittleEndian(data); + var staker = new PublicKey(data.Slice(8, PublicKey.Length)); + var withdrawer = new PublicKey(data.Slice(40, PublicKey.Length)); + var unixTimestamp = BinaryPrimitives.ReadInt64LittleEndian(data[72..]); + var epoch = BinaryPrimitives.ReadUInt64LittleEndian(data[80..]); + var custodian = new PublicKey(data.Slice(88, PublicKey.Length)); + return new StakeAccountMetadata( + rentExemptReserve, + new StakeAuthorized(staker, withdrawer), + new StakeLockup(unixTimestamp, epoch, custodian)); + } +} diff --git a/src/SolSharp.Programs/StakeProgram.cs b/src/SolSharp.Programs/StakeProgram.cs new file mode 100644 index 0000000..705049e --- /dev/null +++ b/src/SolSharp.Programs/StakeProgram.cs @@ -0,0 +1,640 @@ +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Builds bincode-compatible instructions for Solana's native Stake program. +public static class StakeProgram +{ + private const uint InitializeDiscriminator = 0; + private const uint AuthorizeDiscriminator = 1; + private const uint DelegateDiscriminator = 2; + private const uint SplitDiscriminator = 3; + private const uint WithdrawDiscriminator = 4; + private const uint DeactivateDiscriminator = 5; + private const uint SetLockupDiscriminator = 6; + private const uint MergeDiscriminator = 7; + private const uint AuthorizeWithSeedDiscriminator = 8; + private const uint InitializeCheckedDiscriminator = 9; + private const uint AuthorizeCheckedDiscriminator = 10; + private const uint AuthorizeCheckedWithSeedDiscriminator = 11; + private const uint SetLockupCheckedDiscriminator = 12; + private const uint GetMinimumDelegationDiscriminator = 13; + private const uint DeactivateDelinquentDiscriminator = 14; + private const uint MoveStakeDiscriminator = 16; + private const uint MoveLamportsDiscriminator = 17; + + private static readonly PublicKey ClockSysvar = PublicKey.Parse(Sysvars.Clock); + private static readonly PublicKey RentSysvar = PublicKey.Parse(Sysvars.Rent); + private static readonly PublicKey StakeHistorySysvar = + PublicKey.Parse("SysvarStakeHistory1111111111111111111111111"); + + private static readonly PublicKey StakeConfig = + PublicKey.Parse("StakeConfig11111111111111111111111111111111"); + + /// The native Stake program address. + public static readonly PublicKey ProgramId = + PublicKey.Parse("Stake11111111111111111111111111111111111111"); + + /// The fixed serialized size of a stake account. + public const int AccountDataLength = StakeAccountState.AccountDataLength; + + /// Initializes an allocated stake account with authorities and a lockup. + /// The uninitialized writable stake account. + /// The stake and withdrawal authorities. + /// The initial withdrawal lockup. + /// The initialize instruction. + public static Instruction Initialize( + PublicKey stakeAccount, + StakeAuthorized authorized, + StakeLockup lockup) + { + var data = ProgramWireEncoding.Build(InitializeDiscriminator, stream => + { + ProgramWireEncoding.WritePublicKey(stream, authorized.Staker); + ProgramWireEncoding.WritePublicKey(stream, authorized.Withdrawer); + ProgramWireEncoding.WriteInt64(stream, lockup.UnixTimestamp); + ProgramWireEncoding.WriteUInt64(stream, lockup.Epoch); + ProgramWireEncoding.WritePublicKey(stream, lockup.Custodian); + }); + + return CreateInstruction( + data, + [AccountMeta.Writable(stakeAccount), AccountMeta.Readonly(RentSysvar)]); + } + + /// Initializes a stake account while requiring the withdrawal authority to sign. + /// The uninitialized writable stake account. + /// The stake and withdrawal authorities. + /// The checked initialize instruction. + public static Instruction InitializeChecked(PublicKey stakeAccount, StakeAuthorized authorized) + => CreateInstruction( + ProgramWireEncoding.Build(InitializeCheckedDiscriminator), + [ + AccountMeta.Writable(stakeAccount), + AccountMeta.Readonly(RentSysvar), + AccountMeta.Readonly(authorized.Staker), + AccountMeta.ReadonlySigner(authorized.Withdrawer) + ]); + + /// Creates and initializes a stake account. + /// The funding signer. + /// The new stake-account signer. + /// The stake and withdrawal authorities. + /// The initial lockup. + /// The lamports to fund. + /// The System create instruction followed by Stake initialize. + public static Instruction[] CreateAccount( + PublicKey payer, + PublicKey stakeAccount, + StakeAuthorized authorized, + StakeLockup lockup, + ulong lamports) + => + [ + SystemProgram.CreateAccount(payer, stakeAccount, lamports, AccountDataLength, ProgramId), + Initialize(stakeAccount, authorized, lockup) + ]; + + /// Creates and checked-initializes a stake account. + /// The funding signer. + /// The new stake-account signer. + /// The stake and withdrawal authorities. + /// The lamports to fund. + /// The System create instruction followed by checked initialization. + public static Instruction[] CreateAccountChecked( + PublicKey payer, + PublicKey stakeAccount, + StakeAuthorized authorized, + ulong lamports) + => + [ + SystemProgram.CreateAccount(payer, stakeAccount, lamports, AccountDataLength, ProgramId), + InitializeChecked(stakeAccount, authorized) + ]; + + /// Creates and initializes a stake account derived with a System-program seed. + /// The funding signer. + /// The derived stake-account address. + /// The derivation base signer. + /// The System-program derivation seed. + /// The stake and withdrawal authorities. + /// The initial lockup. + /// The lamports to fund. + /// The System create-with-seed instruction followed by Stake initialize. + public static Instruction[] CreateAccountWithSeed( + PublicKey payer, + PublicKey stakeAccount, + PublicKey baseAccount, + string seed, + StakeAuthorized authorized, + StakeLockup lockup, + ulong lamports) + => + [ + SystemProgram.CreateAccountWithSeed( + payer, + stakeAccount, + baseAccount, + seed, + lamports, + AccountDataLength, + ProgramId), + Initialize(stakeAccount, authorized, lockup) + ]; + + /// Creates and checked-initializes a derived stake account. + /// The funding signer. + /// The derived stake-account address. + /// The derivation base signer. + /// The System-program derivation seed. + /// The stake and withdrawal authorities. + /// The lamports to fund. + /// The System create-with-seed instruction followed by checked initialization. + public static Instruction[] CreateAccountWithSeedChecked( + PublicKey payer, + PublicKey stakeAccount, + PublicKey baseAccount, + string seed, + StakeAuthorized authorized, + ulong lamports) + => + [ + SystemProgram.CreateAccountWithSeed( + payer, + stakeAccount, + baseAccount, + seed, + lamports, + AccountDataLength, + ProgramId), + InitializeChecked(stakeAccount, authorized) + ]; + + /// Delegates all stake in an initialized account to a vote account. + /// The writable stake account. + /// The stake-authority signer. + /// The vote account receiving the delegation. + /// The delegate instruction. + public static Instruction DelegateStake( + PublicKey stakeAccount, + PublicKey stakeAuthority, + PublicKey voteAccount) + => CreateInstruction( + ProgramWireEncoding.Build(DelegateDiscriminator), + [ + AccountMeta.Writable(stakeAccount), + AccountMeta.Readonly(voteAccount), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.Readonly(StakeHistorySysvar), + AccountMeta.Readonly(StakeConfig), + AccountMeta.ReadonlySigner(stakeAuthority) + ]); + + /// Creates, initializes, and delegates a stake account. + /// The funding signer. + /// The new stake-account signer. + /// The vote account receiving the delegation. + /// The stake and withdrawal authorities. + /// The initial lockup. + /// The lamports to fund. + /// The create, initialize, and delegate instructions. + public static Instruction[] CreateAccountAndDelegateStake( + PublicKey payer, + PublicKey stakeAccount, + PublicKey voteAccount, + StakeAuthorized authorized, + StakeLockup lockup, + ulong lamports) + { + var instructions = CreateAccount(payer, stakeAccount, authorized, lockup, lamports); + return [.. instructions, DelegateStake(stakeAccount, authorized.Staker, voteAccount)]; + } + + /// Creates, initializes, and delegates a derived stake account. + /// The funding signer. + /// The derived stake account. + /// The derivation base signer. + /// The System-program derivation seed. + /// The vote account receiving the delegation. + /// The stake and withdrawal authorities. + /// The initial lockup. + /// The lamports to fund. + /// The create-with-seed, initialize, and delegate instructions. + public static Instruction[] CreateAccountWithSeedAndDelegateStake( + PublicKey payer, + PublicKey stakeAccount, + PublicKey baseAccount, + string seed, + PublicKey voteAccount, + StakeAuthorized authorized, + StakeLockup lockup, + ulong lamports) + { + var instructions = CreateAccountWithSeed( + payer, + stakeAccount, + baseAccount, + seed, + authorized, + lockup, + lamports); + return [.. instructions, DelegateStake(stakeAccount, authorized.Staker, voteAccount)]; + } + + /// Splits lamports and stake into an uninitialized account. + /// The source stake account. + /// The stake-authority signer. + /// The amount to split. + /// The destination account, which must sign System allocation. + /// Allocate, assign, and split instructions. + public static Instruction[] SplitStake( + PublicKey stakeAccount, + PublicKey stakeAuthority, + ulong lamports, + PublicKey splitStakeAccount) + => + [ + SystemProgram.Allocate(splitStakeAccount, AccountDataLength), + SystemProgram.Assign(splitStakeAccount, ProgramId), + SplitStakeInstruction(stakeAccount, stakeAuthority, lamports, splitStakeAccount) + ]; + + /// Splits stake into a destination derived with a System-program seed. + /// The source stake account. + /// The stake-authority signer. + /// The amount to split. + /// The derived destination account. + /// The derivation base signer. + /// The System-program derivation seed. + /// Allocate-with-seed and split instructions. + public static Instruction[] SplitStakeWithSeed( + PublicKey stakeAccount, + PublicKey stakeAuthority, + ulong lamports, + PublicKey splitStakeAccount, + PublicKey baseAccount, + string seed) + => + [ + SystemProgram.AllocateWithSeed(splitStakeAccount, baseAccount, seed, AccountDataLength, ProgramId), + SplitStakeInstruction(stakeAccount, stakeAuthority, lamports, splitStakeAccount) + ]; + + /// Builds only the native Stake split instruction for an already allocated destination. + /// The source stake account. + /// The stake-authority signer. + /// The amount to split. + /// The uninitialized writable destination. + /// The split instruction. + public static Instruction SplitStakeInstruction( + PublicKey stakeAccount, + PublicKey stakeAuthority, + ulong lamports, + PublicKey splitStakeAccount) + => CreateInstruction( + ProgramWireEncoding.Build( + SplitDiscriminator, + stream => ProgramWireEncoding.WriteUInt64(stream, lamports)), + [ + AccountMeta.Writable(stakeAccount), + AccountMeta.Writable(splitStakeAccount), + AccountMeta.ReadonlySigner(stakeAuthority) + ]); + + /// Merges a compatible source stake account into a destination. + /// The writable destination. + /// The writable source that will be drained. + /// The shared stake-authority signer. + /// The merge instruction. + public static Instruction Merge( + PublicKey destinationStakeAccount, + PublicKey sourceStakeAccount, + PublicKey stakeAuthority) + => CreateInstruction( + ProgramWireEncoding.Build(MergeDiscriminator), + [ + AccountMeta.Writable(destinationStakeAccount), + AccountMeta.Writable(sourceStakeAccount), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.Readonly(StakeHistorySysvar), + AccountMeta.ReadonlySigner(stakeAuthority) + ]); + + /// Changes a stake or withdrawal authority. + /// The writable stake account. + /// The current authority signer. + /// The replacement authority. + /// The authority role to replace. + /// An optional lockup-custodian signer. + /// The authorize instruction. + public static Instruction Authorize( + PublicKey stakeAccount, + PublicKey currentAuthority, + PublicKey newAuthority, + StakeAuthorityType authorityType, + PublicKey? custodian = null) + { + ValidateAuthorityType(authorityType); + var accounts = new List + { + AccountMeta.Writable(stakeAccount), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.ReadonlySigner(currentAuthority) + }; + AddOptionalSigner(accounts, custodian); + + return CreateInstruction( + ProgramWireEncoding.Build(AuthorizeDiscriminator, stream => + { + ProgramWireEncoding.WritePublicKey(stream, newAuthority); + ProgramWireEncoding.WriteUInt32(stream, (uint)authorityType); + }), + accounts); + } + + /// Changes an authority and requires the replacement key to sign. + /// The writable stake account. + /// The current authority signer. + /// The replacement authority signer. + /// The authority role to replace. + /// An optional lockup-custodian signer. + /// The checked authorize instruction. + public static Instruction AuthorizeChecked( + PublicKey stakeAccount, + PublicKey currentAuthority, + PublicKey newAuthority, + StakeAuthorityType authorityType, + PublicKey? custodian = null) + { + ValidateAuthorityType(authorityType); + var accounts = new List + { + AccountMeta.Writable(stakeAccount), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.ReadonlySigner(currentAuthority), + AccountMeta.ReadonlySigner(newAuthority) + }; + AddOptionalSigner(accounts, custodian); + + return CreateInstruction( + ProgramWireEncoding.Build( + AuthorizeCheckedDiscriminator, + stream => ProgramWireEncoding.WriteUInt32(stream, (uint)authorityType)), + accounts); + } + + /// Changes an authority using a signer derived from a base key and seed. + /// The writable stake account. + /// The current authority's base signer. + /// The current authority's seed. + /// The program owner used in the derivation. + /// The replacement authority. + /// The authority role to replace. + /// An optional lockup-custodian signer. + /// The authorize-with-seed instruction. + public static Instruction AuthorizeWithSeed( + PublicKey stakeAccount, + PublicKey authorityBase, + string authoritySeed, + PublicKey authorityOwner, + PublicKey newAuthority, + StakeAuthorityType authorityType, + PublicKey? custodian = null) + { + ValidateAuthorityType(authorityType); + var accounts = AuthorityWithSeedAccounts(stakeAccount, authorityBase, null, custodian); + var data = ProgramWireEncoding.Build(AuthorizeWithSeedDiscriminator, stream => + { + ProgramWireEncoding.WritePublicKey(stream, newAuthority); + ProgramWireEncoding.WriteUInt32(stream, (uint)authorityType); + ProgramWireEncoding.WriteString(stream, authoritySeed, nameof(authoritySeed)); + ProgramWireEncoding.WritePublicKey(stream, authorityOwner); + }); + return CreateInstruction(data, accounts); + } + + /// Changes a derived authority and requires the replacement key to sign. + /// The writable stake account. + /// The current authority's base signer. + /// The current authority's seed. + /// The program owner used in the derivation. + /// The replacement authority signer. + /// The authority role to replace. + /// An optional lockup-custodian signer. + /// The checked authorize-with-seed instruction. + public static Instruction AuthorizeCheckedWithSeed( + PublicKey stakeAccount, + PublicKey authorityBase, + string authoritySeed, + PublicKey authorityOwner, + PublicKey newAuthority, + StakeAuthorityType authorityType, + PublicKey? custodian = null) + { + ValidateAuthorityType(authorityType); + var accounts = AuthorityWithSeedAccounts(stakeAccount, authorityBase, newAuthority, custodian); + var data = ProgramWireEncoding.Build(AuthorizeCheckedWithSeedDiscriminator, stream => + { + ProgramWireEncoding.WriteUInt32(stream, (uint)authorityType); + ProgramWireEncoding.WriteString(stream, authoritySeed, nameof(authoritySeed)); + ProgramWireEncoding.WritePublicKey(stream, authorityOwner); + }); + return CreateInstruction(data, accounts); + } + + /// Withdraws unstaked lamports from a stake account. + /// The writable stake account. + /// The withdrawal-authority signer. + /// The writable recipient. + /// The amount to withdraw. + /// An optional lockup-custodian signer. + /// The withdraw instruction. + public static Instruction Withdraw( + PublicKey stakeAccount, + PublicKey withdrawAuthority, + PublicKey recipient, + ulong lamports, + PublicKey? custodian = null) + { + var accounts = new List + { + AccountMeta.Writable(stakeAccount), + AccountMeta.Writable(recipient), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.Readonly(StakeHistorySysvar), + AccountMeta.ReadonlySigner(withdrawAuthority) + }; + AddOptionalSigner(accounts, custodian); + return CreateInstruction( + ProgramWireEncoding.Build( + WithdrawDiscriminator, + stream => ProgramWireEncoding.WriteUInt64(stream, lamports)), + accounts); + } + + /// Deactivates delegated stake. + /// The writable delegated stake account. + /// The stake-authority signer. + /// The deactivate instruction. + public static Instruction Deactivate(PublicKey stakeAccount, PublicKey stakeAuthority) + => CreateInstruction( + ProgramWireEncoding.Build(DeactivateDiscriminator), + [ + AccountMeta.Writable(stakeAccount), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.ReadonlySigner(stakeAuthority) + ]); + + /// Updates selected lockup values. + /// The writable initialized stake account. + /// The optional replacement values. + /// The lockup or withdrawal-authority signer. + /// The set-lockup instruction. + public static Instruction SetLockup( + PublicKey stakeAccount, + StakeLockupArguments arguments, + PublicKey authority) + => CreateInstruction( + EncodeLockup(SetLockupDiscriminator, arguments, includeCustodian: true), + [AccountMeta.Writable(stakeAccount), AccountMeta.ReadonlySigner(authority)]); + + /// Updates lockup values and requires a replacement custodian to sign. + /// The writable initialized stake account. + /// The optional replacement values. + /// The current lockup or withdrawal-authority signer. + /// The checked set-lockup instruction. + public static Instruction SetLockupChecked( + PublicKey stakeAccount, + StakeLockupArguments arguments, + PublicKey authority) + { + var accounts = new List + { + AccountMeta.Writable(stakeAccount), + AccountMeta.ReadonlySigner(authority) + }; + AddOptionalSigner(accounts, arguments.Custodian); + return CreateInstruction( + EncodeLockup(SetLockupCheckedDiscriminator, arguments, includeCustodian: false), + accounts); + } + + /// Requests the runtime's current minimum stake delegation as return data. + /// The account-free query instruction. + public static Instruction GetMinimumDelegation() + => CreateInstruction(ProgramWireEncoding.Build(GetMinimumDelegationDiscriminator), []); + + /// Deactivates stake delegated to a sufficiently delinquent vote account. + /// The writable delegated stake account. + /// The delinquent vote account. + /// A recently voting reference account. + /// The permissionless delinquent-deactivation instruction. + public static Instruction DeactivateDelinquent( + PublicKey stakeAccount, + PublicKey delinquentVoteAccount, + PublicKey referenceVoteAccount) + => CreateInstruction( + ProgramWireEncoding.Build(DeactivateDelinquentDiscriminator), + [ + AccountMeta.Writable(stakeAccount), + AccountMeta.Readonly(delinquentVoteAccount), + AccountMeta.Readonly(referenceVoteAccount) + ]); + + /// Moves active stake between compatible stake accounts. + /// The writable source stake account. + /// The writable destination stake account. + /// The shared stake-authority signer. + /// The active stake to move. + /// The move-stake instruction. + public static Instruction MoveStake( + PublicKey sourceStakeAccount, + PublicKey destinationStakeAccount, + PublicKey stakeAuthority, + ulong lamports) + => Move( + MoveStakeDiscriminator, + sourceStakeAccount, + destinationStakeAccount, + stakeAuthority, + lamports); + + /// Moves unstaked lamports between compatible stake accounts. + /// The writable source stake account. + /// The writable destination stake account. + /// The shared stake-authority signer. + /// The unstaked lamports to move. + /// The move-lamports instruction. + public static Instruction MoveLamports( + PublicKey sourceStakeAccount, + PublicKey destinationStakeAccount, + PublicKey stakeAuthority, + ulong lamports) + => Move( + MoveLamportsDiscriminator, + sourceStakeAccount, + destinationStakeAccount, + stakeAuthority, + lamports); + + private static Instruction Move( + uint discriminator, + PublicKey source, + PublicKey destination, + PublicKey authority, + ulong lamports) + => CreateInstruction( + ProgramWireEncoding.Build( + discriminator, + stream => ProgramWireEncoding.WriteUInt64(stream, lamports)), + [ + AccountMeta.Writable(source), + AccountMeta.Writable(destination), + AccountMeta.ReadonlySigner(authority) + ]); + + private static byte[] EncodeLockup( + uint discriminator, + StakeLockupArguments arguments, + bool includeCustodian) + => ProgramWireEncoding.Build(discriminator, stream => + { + ProgramWireEncoding.WriteOptionalInt64(stream, arguments.UnixTimestamp); + ProgramWireEncoding.WriteOptionalUInt64(stream, arguments.Epoch); + if (includeCustodian) + ProgramWireEncoding.WriteOptionalPublicKey(stream, arguments.Custodian); + }); + + private static List AuthorityWithSeedAccounts( + PublicKey stakeAccount, + PublicKey authorityBase, + PublicKey? newAuthority, + PublicKey? custodian) + { + var accounts = new List + { + AccountMeta.Writable(stakeAccount), + AccountMeta.ReadonlySigner(authorityBase), + AccountMeta.Readonly(ClockSysvar) + }; + AddOptionalSigner(accounts, newAuthority); + AddOptionalSigner(accounts, custodian); + return accounts; + } + + private static void AddOptionalSigner(List accounts, PublicKey? signer) + { + if (signer is { } publicKey) + accounts.Add(AccountMeta.ReadonlySigner(publicKey)); + } + + private static void ValidateAuthorityType(StakeAuthorityType authorityType) + { + if (authorityType is not StakeAuthorityType.Staker and not StakeAuthorityType.Withdrawer) + throw new ArgumentOutOfRangeException(nameof(authorityType), authorityType, "Unknown stake authority role."); + } + + private static Instruction CreateInstruction(byte[] data, IReadOnlyList accounts) + => new() { ProgramId = ProgramId, Accounts = accounts, Data = data }; +} diff --git a/src/SolSharp.Programs/StakeProgramModels.cs b/src/SolSharp.Programs/StakeProgramModels.cs new file mode 100644 index 0000000..4e5f2bd --- /dev/null +++ b/src/SolSharp.Programs/StakeProgramModels.cs @@ -0,0 +1,79 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Identifies which stake-account authority is being changed. +public enum StakeAuthorityType : uint +{ + /// The authority that delegates, deactivates, splits, merges, or moves stake. + Staker = 0, + + /// The authority that withdraws funds and can change either authority. + Withdrawer = 1 +} + +/// The two authorities stored in an initialized stake account. +/// The stake-management authority. +/// The withdrawal authority. +public readonly record struct StakeAuthorized(PublicKey Staker, PublicKey Withdrawer) +{ + /// Creates an authority pair that uses one key for both roles. + /// The key to use for both roles. + /// The authority pair. + public static StakeAuthorized ForSingleAuthority(PublicKey authority) => new(authority, authority); +} + +/// The withdrawal lockup stored in a stake account. +/// The earliest permitted withdrawal Unix timestamp. +/// The earliest permitted withdrawal epoch. +/// The authority that may override an active lockup. +public readonly record struct StakeLockup(long UnixTimestamp, ulong Epoch, PublicKey Custodian); + +/// Optional values used to update a stake-account lockup. +/// A replacement Unix timestamp, or null to preserve it. +/// A replacement epoch, or null to preserve it. +/// A replacement custodian, or null to preserve it. +public readonly record struct StakeLockupArguments(long? UnixTimestamp, ulong? Epoch, PublicKey? Custodian); + +/// The serialized stake-account state variant. +public enum StakeAccountStateKind : uint +{ + /// The account has not been initialized. + Uninitialized = 0, + + /// The account has authorities and a lockup but no delegation. + Initialized = 1, + + /// The account contains a stake delegation. + Stake = 2, + + /// The legacy rewards-pool variant. + RewardsPool = 3 +} + +/// Initialization metadata stored in a stake account. +/// The historical rent-exempt reserve captured at initialization. +/// The stake and withdrawal authorities. +/// The account's withdrawal lockup. +public readonly record struct StakeAccountMetadata( + ulong RentExemptReserve, + StakeAuthorized Authorized, + StakeLockup Lockup); + +/// A delegation stored in a delegated stake account. +/// The vote account receiving the delegation. +/// The delegated lamports. +/// The epoch in which activation began. +/// The epoch in which deactivation began, or . +/// The reserved 64-bit compatibility field. +public readonly record struct StakeDelegation( + PublicKey Voter, + ulong Lamports, + ulong ActivationEpoch, + ulong DeactivationEpoch, + ulong Reserved); + +/// The delegation and observed vote credits stored in a stake account. +/// The stake delegation. +/// Vote credits observed when the stake was delegated or redeemed. +public readonly record struct StakeDelegatedData(StakeDelegation Delegation, ulong CreditsObserved); diff --git a/src/SolSharp.Programs/SystemProgram.cs b/src/SolSharp.Programs/SystemProgram.cs index c65d779..02816f2 100644 --- a/src/SolSharp.Programs/SystemProgram.cs +++ b/src/SolSharp.Programs/SystemProgram.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Text; using SolSharp.Core.Constants; using SolSharp.Core.Primitives; @@ -25,12 +26,16 @@ public static class SystemProgram private const uint AllocateWithSeedDiscriminator = 9; private const uint AssignWithSeedDiscriminator = 10; private const uint TransferWithSeedDiscriminator = 11; + private const uint UpgradeNonceAccountDiscriminator = 12; + private const uint CreateAccountAllowPrefundDiscriminator = 13; + private const int MaxSeedLength = 32; /// The serialized size of a durable nonce account, in bytes (80). public const int NonceAccountLength = 80; private static readonly PublicKey RentSysvar = PublicKey.Parse(Sysvars.Rent); private static readonly PublicKey RecentBlockhashesSysvar = PublicKey.Parse(Sysvars.RecentBlockhashes); + private static readonly UTF8Encoding StrictUtf8 = new(false, true); /// Builds a transfer of lamports from one account to another. /// The funding account; signs the transaction and is debited. @@ -51,6 +56,23 @@ public static Instruction Transfer(PublicKey from, PublicKey to, ulong lamports) }; } + /// Builds one canonical System transfer instruction for each destination and amount. + /// The funding account used by every transfer; signs and is debited. + /// Destination and lamport pairs, preserved in caller order. + /// One transfer instruction per supplied pair; an empty input yields an empty array. + /// is null. + public static Instruction[] TransferMany( + PublicKey from, + params (PublicKey Recipient, ulong Lamports)[] transfers) + { + ArgumentNullException.ThrowIfNull(transfers); + + var instructions = new Instruction[transfers.Length]; + for (var i = 0; i < transfers.Length; i++) + instructions[i] = Transfer(from, transfers[i].Recipient, transfers[i].Lamports); + return instructions; + } + /// Builds an instruction that creates a new account, funds it, and assigns its owner. /// The funding account; signs the transaction and pays for the new account. /// The address of the account to create; must also sign. @@ -74,6 +96,48 @@ public static Instruction CreateAccount(PublicKey from, PublicKey newAccount, ul }; } + /// + /// Builds the current System Program instruction that initializes an already prefunded account, optionally + /// transferring additional lamports from before allocating and assigning it. + /// + /// The prefunded account to initialize; writable and required to sign. + /// The number of bytes to allocate for the account's data. + /// The program that will own the initialized account. + /// Additional lamports to transfer from ; zero by default. + /// + /// Optional writable funding signer. Supply it when is nonzero; omit it when the + /// new account already contains all required lamports. + /// + /// The create-account-allow-prefund instruction. + /// is nonzero but is absent. + public static Instruction CreateAccountAllowPrefund( + PublicKey newAccount, + ulong space, + PublicKey owner, + ulong lamports = 0, + PublicKey? payer = null) + { + if (lamports != 0 && payer is null) + throw new ArgumentException("A payer is required when additional lamports are requested.", nameof(payer)); + + var data = new byte[52]; + BinaryPrimitives.WriteUInt32LittleEndian(data, CreateAccountAllowPrefundDiscriminator); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(4), lamports); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(12), space); + owner.CopyTo(data.AsSpan(20)); + + var accounts = payer is { } fundingAccount + ? new[] { AccountMeta.WritableSigner(newAccount), AccountMeta.WritableSigner(fundingAccount) } + : [AccountMeta.WritableSigner(newAccount)]; + + return new Instruction + { + ProgramId = ProgramId, + Accounts = accounts, + Data = data + }; + } + /// /// Creates an account at an address derived from a base key and a seed (create_with_seed), funds it, /// and assigns its owner. The base account signs in place of the created address. @@ -87,6 +151,9 @@ public static Instruction CreateAccount(PublicKey from, PublicKey newAccount, ul /// The program that will own the new account. /// The createAccountWithSeed instruction. /// is null. + /// + /// contains invalid Unicode text or is longer than 32 bytes when UTF-8 encoded. + /// public static Instruction CreateAccountWithSeed( PublicKey from, PublicKey createdAccount, @@ -96,8 +163,7 @@ public static Instruction CreateAccountWithSeed( ulong space, PublicKey owner) { - ArgumentNullException.ThrowIfNull(seed); - var seedBytes = System.Text.Encoding.UTF8.GetBytes(seed); + var seedBytes = EncodeSeed(seed); using var buffer = new MemoryStream(52 + seedBytes.Length); Span word = stackalloc byte[8]; @@ -172,10 +238,12 @@ public static Instruction Allocate(PublicKey account, ulong space) /// The program that will own the account. /// The allocateWithSeed instruction. /// is null. + /// + /// contains invalid Unicode text or is longer than 32 bytes when UTF-8 encoded. + /// public static Instruction AllocateWithSeed(PublicKey account, PublicKey baseAccount, string seed, ulong space, PublicKey owner) { - ArgumentNullException.ThrowIfNull(seed); - var seedBytes = System.Text.Encoding.UTF8.GetBytes(seed); + var seedBytes = EncodeSeed(seed); using var buffer = new MemoryStream(84 + seedBytes.Length); Span word = stackalloc byte[8]; @@ -208,10 +276,12 @@ public static Instruction AllocateWithSeed(PublicKey account, PublicKey baseAcco /// The program to set as the new owner. /// The assignWithSeed instruction. /// is null. + /// + /// contains invalid Unicode text or is longer than 32 bytes when UTF-8 encoded. + /// public static Instruction AssignWithSeed(PublicKey account, PublicKey baseAccount, string seed, PublicKey owner) { - ArgumentNullException.ThrowIfNull(seed); - var seedBytes = System.Text.Encoding.UTF8.GetBytes(seed); + var seedBytes = EncodeSeed(seed); using var buffer = new MemoryStream(76 + seedBytes.Length); Span word = stackalloc byte[8]; @@ -244,6 +314,9 @@ public static Instruction AssignWithSeed(PublicKey account, PublicKey baseAccoun /// The amount to transfer, in lamports. /// The transferWithSeed instruction. /// is null. + /// + /// contains invalid Unicode text or is longer than 32 bytes when UTF-8 encoded. + /// public static Instruction TransferWithSeed( PublicKey from, PublicKey baseAccount, @@ -252,8 +325,7 @@ public static Instruction TransferWithSeed( PublicKey to, ulong lamports) { - ArgumentNullException.ThrowIfNull(seed); - var seedBytes = System.Text.Encoding.UTF8.GetBytes(seed); + var seedBytes = EncodeSeed(seed); using var buffer = new MemoryStream(52 + seedBytes.Length); Span word = stackalloc byte[8]; @@ -297,6 +369,42 @@ public static Instruction[] CreateNonceAccount(PublicKey payer, PublicKey nonceA InitializeNonceAccount(nonceAccount, authority) ]; + /// + /// Builds the two instructions that create a durable nonce account at a System + /// create_with_seed address and initialize its authority. The derived nonce address does + /// not sign; the base account signs in its place. + /// + /// The funding account; signs and pays for the nonce account. + /// The derived nonce-account address to create. + /// The base key used to derive ; signs. + /// The seed used to derive . + /// The authority allowed to advance and withdraw the nonce. + /// The rent-exempt lamports to deposit. + /// The seeded create-account instruction followed by initialize-nonce. + /// is null. + /// + /// contains invalid Unicode or exceeds 32 UTF-8 bytes. + /// + public static Instruction[] CreateNonceAccountWithSeed( + PublicKey payer, + PublicKey nonceAccount, + PublicKey baseAccount, + string seed, + PublicKey authority, + ulong lamports) + => + [ + CreateAccountWithSeed( + payer, + nonceAccount, + baseAccount, + seed, + lamports, + NonceAccountLength, + ProgramId), + InitializeNonceAccount(nonceAccount, authority) + ]; + /// Initializes a created account as a durable nonce account controlled by . /// The account to initialize as a nonce account (writable). /// The authority allowed to advance and withdraw the nonce. @@ -387,4 +495,41 @@ public static Instruction AuthorizeNonceAccount(PublicKey nonceAccount, PublicKe Data = data }; } + + /// Upgrades a legacy durable nonce account to the current nonce-state format. + /// The legacy nonce account to upgrade (writable). + /// The upgradeNonceAccount instruction. + public static Instruction UpgradeNonceAccount(PublicKey nonceAccount) + { + var data = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(data, UpgradeNonceAccountDiscriminator); + + return new Instruction + { + ProgramId = ProgramId, + Accounts = [AccountMeta.Writable(nonceAccount)], + Data = data + }; + } + + private static byte[] EncodeSeed(string seed) + { + ArgumentNullException.ThrowIfNull(seed); + byte[] bytes; + try + { + bytes = StrictUtf8.GetBytes(seed); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException("A system-program seed must contain valid Unicode text.", nameof(seed), exception); + } + + if (bytes.Length > MaxSeedLength) + throw new ArgumentException( + $"A system-program seed may be at most {MaxSeedLength} bytes when UTF-8 encoded, got {bytes.Length}.", + nameof(seed)); + + return bytes; + } } diff --git a/src/SolSharp.Programs/Token2022ExtensionType.cs b/src/SolSharp.Programs/Token2022ExtensionType.cs new file mode 100644 index 0000000..fd9efe3 --- /dev/null +++ b/src/SolSharp.Programs/Token2022ExtensionType.cs @@ -0,0 +1,95 @@ +namespace SolSharp.Programs; + +/// +/// A Token-2022 extension type as encoded in account-size and reallocation instructions. Values mirror the +/// pinned spl_token_2022_interface::extension::ExtensionType wire tags. +/// +public enum Token2022ExtensionType : ushort +{ + /// Padding or an uninitialized extension entry. + Uninitialized = 0, + + /// Mint transfer-fee configuration. + TransferFeeConfig = 1, + + /// Account withheld transfer-fee amount. + TransferFeeAmount = 2, + + /// Mint close authority. + MintCloseAuthority = 3, + + /// Confidential-transfer mint configuration. + ConfidentialTransferMint = 4, + + /// Confidential-transfer account state. + ConfidentialTransferAccount = 5, + + /// Default state for newly initialized token accounts. + DefaultAccountState = 6, + + /// Immutable token-account owner marker. + ImmutableOwner = 7, + + /// Required incoming-transfer memo state. + MemoTransfer = 8, + + /// Non-transferable mint marker. + NonTransferable = 9, + + /// Interest-bearing mint configuration. + InterestBearingConfig = 10, + + /// Token-account CPI guard state. + CpiGuard = 11, + + /// Mint permanent delegate. + PermanentDelegate = 12, + + /// Non-transferable token-account marker. + NonTransferableAccount = 13, + + /// Mint transfer-hook configuration. + TransferHook = 14, + + /// Token-account transfer-hook state. + TransferHookAccount = 15, + + /// Confidential transfer-fee mint configuration. + ConfidentialTransferFeeConfig = 16, + + /// Confidential withheld-fee account state. + ConfidentialTransferFeeAmount = 17, + + /// Mint metadata pointer. + MetadataPointer = 18, + + /// In-mint token metadata. + TokenMetadata = 19, + + /// Mint group pointer. + GroupPointer = 20, + + /// In-mint token-group configuration. + TokenGroup = 21, + + /// Mint group-member pointer. + GroupMemberPointer = 22, + + /// In-mint token-group-member configuration. + TokenGroupMember = 23, + + /// Confidential mint and burn configuration. + ConfidentialMintBurn = 24, + + /// Scaled UI amount configuration. + ScaledUiAmount = 25, + + /// Pausable mint configuration. + Pausable = 26, + + /// Pausable token-account marker. + PausableAccount = 27, + + /// Permissioned-burn mint configuration. + PermissionedBurn = 28 +} diff --git a/src/SolSharp.Programs/Token2022Program.AccountExtensions.cs b/src/SolSharp.Programs/Token2022Program.AccountExtensions.cs new file mode 100644 index 0000000..f9d198e --- /dev/null +++ b/src/SolSharp.Programs/Token2022Program.AccountExtensions.cs @@ -0,0 +1,132 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +public static partial class Token2022Program +{ + private const byte DefaultAccountStateExtensionDiscriminator = 28; + private const byte MemoTransferExtensionDiscriminator = 30; + private const byte CpiGuardExtensionDiscriminator = 34; + + /// Initializes the default state applied to new accounts of a Token-2022 mint. + /// The uninitialized writable mint. + /// The default account state. + /// The default-account-state initialize instruction. + /// is not defined. + public static Instruction InitializeDefaultAccountState(PublicKey mint, DefaultTokenAccountState state) + => new() + { + ProgramId = ProgramId, + Accounts = [AccountMeta.Writable(mint)], + Data = DefaultAccountStateData(innerDiscriminator: 0, state) + }; + + /// Updates the default state applied to new accounts of a Token-2022 mint. + /// The writable mint. + /// The mint's freeze authority or multisig authority. + /// The new default account state. + /// + /// Multisig member signers, or null/empty when signs directly. + /// + /// The default-account-state update instruction. + /// contains more than 11 accounts. + /// is not defined. + public static Instruction UpdateDefaultAccountState( + PublicKey mint, + PublicKey freezeAuthority, + DefaultTokenAccountState state, + IReadOnlyList? multisigSigners = null) + => AuthorityExtensionInstruction( + mint, + freezeAuthority, + DefaultAccountStateData(innerDiscriminator: 1, state), + multisigSigners); + + /// Requires a memo on every incoming transfer to a Token-2022 account. + /// The writable token account. + /// The account owner or multisig owner. + /// + /// Multisig member signers, or null/empty when signs directly. + /// + /// The memo-transfer enable instruction. + /// contains more than 11 accounts. + public static Instruction EnableRequiredTransferMemos( + PublicKey account, + PublicKey owner, + IReadOnlyList? multisigSigners = null) + => AuthorityExtensionInstruction( + account, + owner, + [MemoTransferExtensionDiscriminator, 0], + multisigSigners); + + /// Stops requiring memos on incoming transfers to a Token-2022 account. + /// The writable token account. + /// The account owner or multisig owner. + /// + /// Multisig member signers, or null/empty when signs directly. + /// + /// The memo-transfer disable instruction. + /// contains more than 11 accounts. + public static Instruction DisableRequiredTransferMemos( + PublicKey account, + PublicKey owner, + IReadOnlyList? multisigSigners = null) + => AuthorityExtensionInstruction( + account, + owner, + [MemoTransferExtensionDiscriminator, 1], + multisigSigners); + + /// Enables the Token-2022 CPI guard on a token account. + /// The writable token account. + /// The account owner or multisig owner. + /// + /// Multisig member signers, or null/empty when signs directly. + /// + /// The CPI-guard enable instruction. + /// contains more than 11 accounts. + public static Instruction EnableCpiGuard( + PublicKey account, + PublicKey owner, + IReadOnlyList? multisigSigners = null) + => AuthorityExtensionInstruction(account, owner, [CpiGuardExtensionDiscriminator, 0], multisigSigners); + + /// Disables the Token-2022 CPI guard on a token account. + /// The writable token account. + /// The account owner or multisig owner. + /// + /// Multisig member signers, or null/empty when signs directly. + /// + /// The CPI-guard disable instruction. + /// contains more than 11 accounts. + public static Instruction DisableCpiGuard( + PublicKey account, + PublicKey owner, + IReadOnlyList? multisigSigners = null) + => AuthorityExtensionInstruction(account, owner, [CpiGuardExtensionDiscriminator, 1], multisigSigners); + + private static byte[] DefaultAccountStateData(byte innerDiscriminator, DefaultTokenAccountState state) + { + if ((byte)state > (byte)DefaultTokenAccountState.Frozen) + throw new ArgumentOutOfRangeException(nameof(state), state, "Unknown token account state."); + return [DefaultAccountStateExtensionDiscriminator, innerDiscriminator, (byte)state]; + } + + private static Instruction AuthorityExtensionInstruction( + PublicKey target, + PublicKey authority, + byte[] data, + IReadOnlyList? multisigSigners) + { + var instruction = new Instruction + { + ProgramId = ProgramId, + Accounts = [AccountMeta.Writable(target), AccountMeta.ReadonlySigner(authority)], + Data = data + }; + return multisigSigners is { Count: > 0 } + ? WithMultisigAuthority(instruction, authorityIndex: 1, multisigSigners) + : instruction; + } +} diff --git a/src/SolSharp.Programs/Token2022Program.ConfidentialMintBurn.cs b/src/SolSharp.Programs/Token2022Program.ConfidentialMintBurn.cs new file mode 100644 index 0000000..1f55610 --- /dev/null +++ b/src/SolSharp.Programs/Token2022Program.ConfidentialMintBurn.cs @@ -0,0 +1,199 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +public static partial class Token2022Program +{ + private const byte ConfidentialMintBurnExtensionDiscriminator = 42; + + /// Initializes confidential mint/burn state on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The exact 32-byte confidential-supply ElGamal public-key POD. + /// The exact 36-byte initial decryptable-supply POD. + /// The initialize-confidential-mint-burn instruction. + public static Instruction InitializeConfidentialMintBurn( + PublicKey mint, + ReadOnlySpan supplyElGamalPublicKey, + ReadOnlySpan decryptableSupply) + { + var data = ConfidentialMintBurnData( + innerDiscriminator: 0, + payloadLength: ElGamalPublicKeyLength + DecryptableBalanceLength); + CopyExactPod(data.AsSpan(2, ElGamalPublicKeyLength), supplyElGamalPublicKey, nameof(supplyElGamalPublicKey)); + CopyExactPod( + data.AsSpan(2 + ElGamalPublicKeyLength, DecryptableBalanceLength), + decryptableSupply, + nameof(decryptableSupply)); + return new Instruction { ProgramId = ProgramId, Accounts = [AccountMeta.Writable(mint)], Data = data }; + } + + /// Rotates the confidential-supply ElGamal public key using a precomputed equality proof. + /// The writable mint. + /// The confidential mint authority or multisig authority. + /// The exact 32-byte new supply public-key POD. + /// The ciphertext-ciphertext equality proof location. + /// Multisig member signers, or null/empty for a direct authority. + /// The rotate-confidential-supply-key instruction. + public static Instruction RotateConfidentialSupplyElGamalPublicKey( + PublicKey mint, + PublicKey authority, + ReadOnlySpan newSupplyElGamalPublicKey, + ConfidentialProofLocation proofLocation, + IReadOnlyList? multisigSigners = null) + { + var accounts = new List { AccountMeta.Writable(mint) }; + var offset = AppendConfidentialProofLocations(accounts, proofLocation)[0]; + AppendAuthority(accounts, authority, multisigSigners); + var data = ConfidentialMintBurnData(innerDiscriminator: 1, payloadLength: ElGamalPublicKeyLength + 1); + CopyExactPod(data.AsSpan(2, ElGamalPublicKeyLength), newSupplyElGamalPublicKey, nameof(newSupplyElGamalPublicKey)); + data[^1] = unchecked((byte)offset); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } + + /// Updates the mint's decryptable confidential supply. + /// The writable mint. + /// The confidential mint authority or multisig authority. + /// The exact 36-byte updated decryptable-supply POD. + /// Multisig member signers, or null/empty for a direct authority. + /// The update-decryptable-supply instruction. + public static Instruction UpdateConfidentialDecryptableSupply( + PublicKey mint, + PublicKey authority, + ReadOnlySpan newDecryptableSupply, + IReadOnlyList? multisigSigners = null) + { + var data = ConfidentialMintBurnData(innerDiscriminator: 2, payloadLength: DecryptableBalanceLength); + CopyExactPod(data.AsSpan(2), newDecryptableSupply, nameof(newDecryptableSupply)); + return ConfidentialAuthorityInstruction([AccountMeta.Writable(mint)], authority, data, multisigSigners); + } + + /// Mints into a confidential balance using caller-generated ciphertexts and three split proofs. + /// The writable destination token account. + /// The writable mint. + /// The exact 36-byte post-mint decryptable supply. + /// The exact 64-byte low mint-amount auditor ciphertext. + /// The exact 64-byte high mint-amount auditor ciphertext. + /// The confidential mint authority or multisig authority. + /// The ciphertext-commitment equality proof location. + /// The batched three-handle validity-proof location. + /// The batched U128 range-proof location. + /// Multisig member signers, or null/empty for a direct authority. + /// The confidential mint instruction. + public static Instruction MintConfidentialTokens( + PublicKey tokenAccount, + PublicKey mint, + ReadOnlySpan newDecryptableSupply, + ReadOnlySpan auditorCiphertextLow, + ReadOnlySpan auditorCiphertextHigh, + PublicKey authority, + ConfidentialProofLocation equalityProofLocation, + ConfidentialProofLocation ciphertextValidityProofLocation, + ConfidentialProofLocation rangeProofLocation, + IReadOnlyList? multisigSigners = null) + => ConfidentialMintOrBurn( + tokenAccount, + mint, + newDecryptableSupply, + auditorCiphertextLow, + auditorCiphertextHigh, + authority, + equalityProofLocation, + ciphertextValidityProofLocation, + rangeProofLocation, + innerDiscriminator: 3, + multisigSigners); + + /// Burns from a confidential balance using caller-generated ciphertexts and three split proofs. + /// The writable source token account. + /// The writable mint. + /// The exact 36-byte post-burn source balance. + /// The exact 64-byte low burn-amount auditor ciphertext. + /// The exact 64-byte high burn-amount auditor ciphertext. + /// The account owner/delegate or multisig authority. + /// The ciphertext-commitment equality proof location. + /// The batched three-handle validity-proof location. + /// The batched U128 range-proof location. + /// Multisig member signers, or null/empty for a direct authority. + /// The confidential burn instruction. + public static Instruction BurnConfidentialTokens( + PublicKey tokenAccount, + PublicKey mint, + ReadOnlySpan newDecryptableAvailableBalance, + ReadOnlySpan auditorCiphertextLow, + ReadOnlySpan auditorCiphertextHigh, + PublicKey authority, + ConfidentialProofLocation equalityProofLocation, + ConfidentialProofLocation ciphertextValidityProofLocation, + ConfidentialProofLocation rangeProofLocation, + IReadOnlyList? multisigSigners = null) + => ConfidentialMintOrBurn( + tokenAccount, + mint, + newDecryptableAvailableBalance, + auditorCiphertextLow, + auditorCiphertextHigh, + authority, + equalityProofLocation, + ciphertextValidityProofLocation, + rangeProofLocation, + innerDiscriminator: 4, + multisigSigners); + + /// Applies a pending confidential burn amount to the mint supply. + /// The writable mint. + /// The confidential mint authority or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The apply-pending-confidential-burn instruction. + public static Instruction ApplyPendingConfidentialBurn( + PublicKey mint, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + => ConfidentialAuthorityInstruction( + [AccountMeta.Writable(mint)], + authority, + ConfidentialMintBurnData(innerDiscriminator: 5), + multisigSigners); + + private static Instruction ConfidentialMintOrBurn( + PublicKey tokenAccount, + PublicKey mint, + ReadOnlySpan newDecryptableBalance, + ReadOnlySpan auditorCiphertextLow, + ReadOnlySpan auditorCiphertextHigh, + PublicKey authority, + ConfidentialProofLocation equalityProofLocation, + ConfidentialProofLocation ciphertextValidityProofLocation, + ConfidentialProofLocation rangeProofLocation, + byte innerDiscriminator, + IReadOnlyList? multisigSigners) + { + var accounts = new List { AccountMeta.Writable(tokenAccount), AccountMeta.Writable(mint) }; + var offsets = AppendConfidentialProofLocations( + accounts, + equalityProofLocation, + ciphertextValidityProofLocation, + rangeProofLocation); + AppendAuthority(accounts, authority, multisigSigners); + var data = ConfidentialMintBurnData( + innerDiscriminator, + payloadLength: DecryptableBalanceLength + (2 * ElGamalCiphertextLength) + 3); + var cursor = 2; + CopyExactPod(data.AsSpan(cursor, DecryptableBalanceLength), newDecryptableBalance, nameof(newDecryptableBalance)); + cursor += DecryptableBalanceLength; + CopyExactPod(data.AsSpan(cursor, ElGamalCiphertextLength), auditorCiphertextLow, nameof(auditorCiphertextLow)); + cursor += ElGamalCiphertextLength; + CopyExactPod(data.AsSpan(cursor, ElGamalCiphertextLength), auditorCiphertextHigh, nameof(auditorCiphertextHigh)); + cursor += ElGamalCiphertextLength; + for (var i = 0; i < offsets.Length; i++) + data[cursor + i] = unchecked((byte)offsets[i]); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } + + private static byte[] ConfidentialMintBurnData(byte innerDiscriminator, int payloadLength = 0) + { + var data = new byte[2 + payloadLength]; + data[0] = ConfidentialMintBurnExtensionDiscriminator; + data[1] = innerDiscriminator; + return data; + } +} diff --git a/src/SolSharp.Programs/Token2022Program.ConfidentialTransfer.cs b/src/SolSharp.Programs/Token2022Program.ConfidentialTransfer.cs new file mode 100644 index 0000000..4b92fe0 --- /dev/null +++ b/src/SolSharp.Programs/Token2022Program.ConfidentialTransfer.cs @@ -0,0 +1,527 @@ +using System.Buffers.Binary; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +public static partial class Token2022Program +{ + /// The byte length of an upstream ElGamal public-key POD. + public const int ElGamalPublicKeyLength = 32; + + /// The byte length of an upstream ElGamal ciphertext POD. + public const int ElGamalCiphertextLength = 64; + + /// The byte length of an upstream authenticated-encryption ciphertext/decryptable balance POD. + public const int DecryptableBalanceLength = 36; + + private const byte ConfidentialTransferExtensionDiscriminator = 27; + private static readonly PublicKey InstructionsSysvar = PublicKey.Parse(Sysvars.Instructions); + + /// Initializes confidential-transfer configuration on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The configuration authority, or null for none. + /// Whether newly configured accounts are immediately approved. + /// An optional exact 32-byte ElGamal public-key POD. + /// The initialize-confidential-transfer-mint instruction. + public static Instruction InitializeConfidentialTransferMint( + PublicKey mint, + PublicKey? authority, + bool autoApproveNewAccounts, + ReadOnlyMemory? auditorElGamalPublicKey = null) + { + var data = ConfidentialData(innerDiscriminator: 0, payloadLength: 65); + WriteMaybeNullPublicKey(data.AsSpan(2), authority, nameof(authority)); + data[2 + PublicKey.Length] = autoApproveNewAccounts ? (byte)1 : (byte)0; + WriteMaybeNullPod( + data.AsSpan(3 + PublicKey.Length, ElGamalPublicKeyLength), + auditorElGamalPublicKey, + ElGamalPublicKeyLength, + nameof(auditorElGamalPublicKey)); + return new Instruction { ProgramId = ProgramId, Accounts = [AccountMeta.Writable(mint)], Data = data }; + } + + /// Updates confidential-transfer configuration on a Token-2022 mint. + /// The writable mint. + /// The current confidential-transfer mint authority. + /// Whether newly configured accounts are immediately approved. + /// An optional exact 32-byte ElGamal public-key POD. + /// Multisig member signers, or null/empty for a direct authority. + /// The update-confidential-transfer-mint instruction. + public static Instruction UpdateConfidentialTransferMint( + PublicKey mint, + PublicKey authority, + bool autoApproveNewAccounts, + ReadOnlyMemory? auditorElGamalPublicKey = null, + IReadOnlyList? multisigSigners = null) + { + var data = ConfidentialData(innerDiscriminator: 1, payloadLength: 33); + data[2] = autoApproveNewAccounts ? (byte)1 : (byte)0; + WriteMaybeNullPod( + data.AsSpan(3, ElGamalPublicKeyLength), + auditorElGamalPublicKey, + ElGamalPublicKeyLength, + nameof(auditorElGamalPublicKey)); + return ConfidentialAuthorityInstruction([AccountMeta.Writable(mint)], authority, data, multisigSigners); + } + + /// + /// Configures confidential transfers on a token account using a caller-provided decryptable zero balance + /// and a precomputed ElGamal public-key-validity proof. + /// + /// The writable token account. + /// The readonly mint. + /// The exact 36-byte authenticated-encryption ciphertext for zero. + /// The maximum unapplied incoming-credit count. + /// The account owner or multisig authority. + /// The validity proof instruction offset or pre-verified context account. + /// Multisig member signers, or null/empty for a direct authority. + /// The configure-confidential-transfer-account instruction. + public static Instruction ConfigureConfidentialTransferAccount( + PublicKey tokenAccount, + PublicKey mint, + ReadOnlySpan decryptableZeroBalance, + ulong maximumPendingBalanceCreditCounter, + PublicKey authority, + ConfidentialProofLocation proofLocation, + IReadOnlyList? multisigSigners = null) + { + var accounts = new List { AccountMeta.Writable(tokenAccount), AccountMeta.Readonly(mint) }; + var offset = AppendConfidentialProofLocations(accounts, proofLocation)[0]; + AppendAuthority(accounts, authority, multisigSigners); + var data = ConfidentialData(innerDiscriminator: 2, payloadLength: 45); + CopyExactPod(data.AsSpan(2, DecryptableBalanceLength), decryptableZeroBalance, nameof(decryptableZeroBalance)); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(2 + DecryptableBalanceLength), maximumPendingBalanceCreditCounter); + data[^1] = unchecked((byte)offset); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } + + /// Approves a configured confidential-transfer token account. + /// The writable token account to approve. + /// The readonly mint. + /// The confidential-transfer mint authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The approve-confidential-transfer-account instruction. + public static Instruction ApproveConfidentialTransferAccount( + PublicKey tokenAccount, + PublicKey mint, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + => ConfidentialAuthorityInstruction( + [AccountMeta.Writable(tokenAccount), AccountMeta.Readonly(mint)], + authority, + ConfidentialData(innerDiscriminator: 3), + multisigSigners); + + /// Empties a confidential available-balance ciphertext using a precomputed zero-ciphertext proof. + /// The writable token account. + /// The account owner or multisig authority. + /// The proof instruction offset or pre-verified context account. + /// Multisig member signers, or null/empty for a direct authority. + /// The empty-confidential-transfer-account instruction. + public static Instruction EmptyConfidentialTransferAccount( + PublicKey tokenAccount, + PublicKey authority, + ConfidentialProofLocation proofLocation, + IReadOnlyList? multisigSigners = null) + { + var accounts = new List { AccountMeta.Writable(tokenAccount) }; + var offset = AppendConfidentialProofLocations(accounts, proofLocation)[0]; + AppendAuthority(accounts, authority, multisigSigners); + var data = ConfidentialData(innerDiscriminator: 4, payloadLength: 1); + data[2] = unchecked((byte)offset); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } + + /// Deposits non-confidential tokens into a confidential pending balance. + /// The writable token account. + /// The readonly mint. + /// The amount in raw token units. + /// The expected mint decimals. + /// The account owner/delegate or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The confidential deposit instruction. + public static Instruction DepositConfidentialTokens( + PublicKey tokenAccount, + PublicKey mint, + ulong amount, + byte decimals, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + { + var data = ConfidentialData(innerDiscriminator: 5, payloadLength: 9); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(2), amount); + data[^1] = decimals; + return ConfidentialAuthorityInstruction( + [AccountMeta.Writable(tokenAccount), AccountMeta.Readonly(mint)], + authority, + data, + multisigSigners); + } + + /// Withdraws from a confidential balance using caller-generated ciphertext and proofs. + /// The writable source token account. + /// The readonly mint. + /// The amount in raw token units. + /// The expected mint decimals. + /// The exact 36-byte post-withdraw decryptable balance. + /// The source owner/delegate or multisig authority. + /// The ciphertext-commitment equality proof location. + /// The batched U64 range-proof location. + /// Multisig member signers, or null/empty for a direct authority. + /// The confidential withdraw instruction. + public static Instruction WithdrawConfidentialTokens( + PublicKey tokenAccount, + PublicKey mint, + ulong amount, + byte decimals, + ReadOnlySpan newDecryptableAvailableBalance, + PublicKey authority, + ConfidentialProofLocation equalityProofLocation, + ConfidentialProofLocation rangeProofLocation, + IReadOnlyList? multisigSigners = null) + { + var accounts = new List { AccountMeta.Writable(tokenAccount), AccountMeta.Readonly(mint) }; + var offsets = AppendConfidentialProofLocations(accounts, equalityProofLocation, rangeProofLocation); + AppendAuthority(accounts, authority, multisigSigners); + var data = ConfidentialData(innerDiscriminator: 6, payloadLength: 47); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(2), amount); + data[2 + sizeof(ulong)] = decimals; + CopyExactPod( + data.AsSpan(3 + sizeof(ulong), DecryptableBalanceLength), + newDecryptableAvailableBalance, + nameof(newDecryptableAvailableBalance)); + data[^2] = unchecked((byte)offsets[0]); + data[^1] = unchecked((byte)offsets[1]); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } + + /// Transfers confidential tokens using caller-generated ciphertexts and split proofs. + /// The writable source token account. + /// The readonly mint. + /// The writable destination token account. + /// The exact 36-byte post-transfer source balance. + /// The exact 64-byte low transfer-amount auditor ciphertext. + /// The exact 64-byte high transfer-amount auditor ciphertext. + /// The source owner/delegate or multisig authority. + /// The ciphertext-commitment equality proof location. + /// The batched three-handle validity-proof location. + /// The batched U128 range-proof location. + /// Multisig member signers, or null/empty for a direct authority. + /// The confidential transfer instruction. + public static Instruction TransferConfidentialTokens( + PublicKey source, + PublicKey mint, + PublicKey destination, + ReadOnlySpan newSourceDecryptableAvailableBalance, + ReadOnlySpan auditorCiphertextLow, + ReadOnlySpan auditorCiphertextHigh, + PublicKey authority, + ConfidentialProofLocation equalityProofLocation, + ConfidentialProofLocation ciphertextValidityProofLocation, + ConfidentialProofLocation rangeProofLocation, + IReadOnlyList? multisigSigners = null) + { + var accounts = new List + { + AccountMeta.Writable(source), + AccountMeta.Readonly(mint), + AccountMeta.Writable(destination) + }; + var offsets = AppendConfidentialProofLocations( + accounts, + equalityProofLocation, + ciphertextValidityProofLocation, + rangeProofLocation); + AppendAuthority(accounts, authority, multisigSigners); + var data = ConfidentialTransferData( + innerDiscriminator: 7, + newSourceDecryptableAvailableBalance, + auditorCiphertextLow, + auditorCiphertextHigh, + offsets, + expectedProofCount: 3); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } + + /// Applies pending confidential credits to the available balance. + /// The writable token account. + /// The expected pending-credit counter. + /// The exact 36-byte updated decryptable balance. + /// The owner or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The apply-pending-confidential-balance instruction. + public static Instruction ApplyConfidentialPendingBalance( + PublicKey tokenAccount, + ulong expectedPendingBalanceCreditCounter, + ReadOnlySpan newDecryptableAvailableBalance, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + { + var data = ConfidentialData(innerDiscriminator: 8, payloadLength: 44); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(2), expectedPendingBalanceCreditCounter); + CopyExactPod( + data.AsSpan(2 + sizeof(ulong), DecryptableBalanceLength), + newDecryptableAvailableBalance, + nameof(newDecryptableAvailableBalance)); + return ConfidentialAuthorityInstruction([AccountMeta.Writable(tokenAccount)], authority, data, multisigSigners); + } + + /// Allows incoming confidential credits on a token account. + /// The writable token account. + /// The owner or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The enable-confidential-credits instruction. + /// contains more than 11 accounts. + public static Instruction EnableConfidentialCredits( + PublicKey tokenAccount, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + => ConfidentialCreditInstruction(tokenAccount, authority, innerDiscriminator: 9, multisigSigners); + + /// Rejects incoming confidential credits on a token account. + /// The writable token account. + /// The owner or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The disable-confidential-credits instruction. + /// contains more than 11 accounts. + public static Instruction DisableConfidentialCredits( + PublicKey tokenAccount, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + => ConfidentialCreditInstruction(tokenAccount, authority, innerDiscriminator: 10, multisigSigners); + + /// Allows incoming non-confidential credits on a confidential token account. + /// The writable token account. + /// The owner or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The enable-non-confidential-credits instruction. + /// contains more than 11 accounts. + public static Instruction EnableNonConfidentialCredits( + PublicKey tokenAccount, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + => ConfidentialCreditInstruction(tokenAccount, authority, innerDiscriminator: 11, multisigSigners); + + /// Rejects incoming non-confidential credits on a confidential token account. + /// The writable token account. + /// The owner or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The disable-non-confidential-credits instruction. + /// contains more than 11 accounts. + public static Instruction DisableNonConfidentialCredits( + PublicKey tokenAccount, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + => ConfidentialCreditInstruction(tokenAccount, authority, innerDiscriminator: 12, multisigSigners); + + /// Transfers confidential tokens with fees using caller-generated ciphertexts and five split proofs. + /// The writable source token account. + /// The readonly mint. + /// The writable destination token account. + /// The exact 36-byte post-transfer source balance. + /// The exact 64-byte low transfer-amount auditor ciphertext. + /// The exact 64-byte high transfer-amount auditor ciphertext. + /// The source owner/delegate or multisig authority. + /// The ciphertext-commitment equality proof location. + /// The transfer-amount ciphertext validity-proof location. + /// The percentage-with-cap proof location. + /// The fee ciphertext validity-proof location. + /// The batched U256 range-proof location. + /// Multisig member signers, or null/empty for a direct authority. + /// The confidential transfer-with-fee instruction. + public static Instruction TransferConfidentialTokensWithFee( + PublicKey source, + PublicKey mint, + PublicKey destination, + ReadOnlySpan newSourceDecryptableAvailableBalance, + ReadOnlySpan auditorCiphertextLow, + ReadOnlySpan auditorCiphertextHigh, + PublicKey authority, + ConfidentialProofLocation equalityProofLocation, + ConfidentialProofLocation transferAmountValidityProofLocation, + ConfidentialProofLocation feeSigmaProofLocation, + ConfidentialProofLocation feeCiphertextValidityProofLocation, + ConfidentialProofLocation rangeProofLocation, + IReadOnlyList? multisigSigners = null) + { + var accounts = new List + { + AccountMeta.Writable(source), + AccountMeta.Readonly(mint), + AccountMeta.Writable(destination) + }; + var offsets = AppendConfidentialProofLocations( + accounts, + equalityProofLocation, + transferAmountValidityProofLocation, + feeSigmaProofLocation, + feeCiphertextValidityProofLocation, + rangeProofLocation); + AppendAuthority(accounts, authority, multisigSigners); + var data = ConfidentialTransferData( + innerDiscriminator: 13, + newSourceDecryptableAvailableBalance, + auditorCiphertextLow, + auditorCiphertextHigh, + offsets, + expectedProofCount: 5); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } + + /// Configures a confidential-transfer account from a wallet's SPL ElGamal registry. + /// The writable token account. + /// The readonly mint. + /// The readonly ElGamal registry account. + /// An optional writable signer funding account reallocation. + /// The configure-account-with-registry instruction. + public static Instruction ConfigureConfidentialTransferAccountWithRegistry( + PublicKey tokenAccount, + PublicKey mint, + PublicKey registryAccount, + PublicKey? payer = null) + { + var accounts = new List + { + AccountMeta.Writable(tokenAccount), + AccountMeta.Readonly(mint), + AccountMeta.Readonly(registryAccount) + }; + if (payer is { } fundingAccount) + { + accounts.Add(AccountMeta.WritableSigner(fundingAccount)); + accounts.Add(AccountMeta.Readonly(SystemProgram.ProgramId)); + } + + return new Instruction + { + ProgramId = ProgramId, + Accounts = accounts, + Data = ConfidentialData(innerDiscriminator: 14) + }; + } + + private static Instruction ConfidentialCreditInstruction( + PublicKey tokenAccount, + PublicKey authority, + byte innerDiscriminator, + IReadOnlyList? multisigSigners) + => ConfidentialAuthorityInstruction( + [AccountMeta.Writable(tokenAccount)], + authority, + ConfidentialData(innerDiscriminator), + multisigSigners); + + private static Instruction ConfidentialAuthorityInstruction( + IReadOnlyList initialAccounts, + PublicKey authority, + byte[] data, + IReadOnlyList? multisigSigners) + { + var accounts = new List(initialAccounts.Count + 1 + (multisigSigners?.Count ?? 0)); + accounts.AddRange(initialAccounts); + AppendAuthority(accounts, authority, multisigSigners); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } + + private static byte[] ConfidentialData(byte innerDiscriminator, int payloadLength = 0) + { + var data = new byte[2 + payloadLength]; + data[0] = ConfidentialTransferExtensionDiscriminator; + data[1] = innerDiscriminator; + return data; + } + + private static byte[] ConfidentialTransferData( + byte innerDiscriminator, + ReadOnlySpan newDecryptableAvailableBalance, + ReadOnlySpan auditorCiphertextLow, + ReadOnlySpan auditorCiphertextHigh, + sbyte[] proofOffsets, + int expectedProofCount) + { + if (proofOffsets.Length != expectedProofCount) + throw new ArgumentException("The confidential instruction has the wrong number of proof offsets.", nameof(proofOffsets)); + + var data = ConfidentialData( + innerDiscriminator, + DecryptableBalanceLength + (2 * ElGamalCiphertextLength) + expectedProofCount); + var cursor = 2; + CopyExactPod( + data.AsSpan(cursor, DecryptableBalanceLength), + newDecryptableAvailableBalance, + nameof(newDecryptableAvailableBalance)); + cursor += DecryptableBalanceLength; + CopyExactPod(data.AsSpan(cursor, ElGamalCiphertextLength), auditorCiphertextLow, nameof(auditorCiphertextLow)); + cursor += ElGamalCiphertextLength; + CopyExactPod(data.AsSpan(cursor, ElGamalCiphertextLength), auditorCiphertextHigh, nameof(auditorCiphertextHigh)); + cursor += ElGamalCiphertextLength; + for (var i = 0; i < proofOffsets.Length; i++) + data[cursor + i] = unchecked((byte)proofOffsets[i]); + return data; + } + + private static sbyte[] AppendConfidentialProofLocations( + List accounts, + params ConfidentialProofLocation[] proofLocations) + { + ArgumentNullException.ThrowIfNull(proofLocations); + for (var i = 0; i < proofLocations.Length; i++) + ArgumentNullException.ThrowIfNull(proofLocations[i], nameof(proofLocations)); + + if (proofLocations.Any(location => location.IsInstructionOffset)) + accounts.Add(AccountMeta.Readonly(InstructionsSysvar)); + + var offsets = new sbyte[proofLocations.Length]; + for (var i = 0; i < proofLocations.Length; i++) + { + var location = proofLocations[i]; + if (location.IsInstructionOffset) + { + offsets[i] = location.InstructionOffset; + } + else + { + accounts.Add(AccountMeta.Readonly(location.ContextStateAccount!.Value)); + offsets[i] = 0; + } + } + + return offsets; + } + + private static void CopyExactPod(Span destination, ReadOnlySpan source, string parameterName) + { + if (source.Length != destination.Length) + throw new ArgumentException( + $"The upstream POD must contain exactly {destination.Length} bytes, got {source.Length}.", + parameterName); + source.CopyTo(destination); + } + + private static void WriteMaybeNullPod( + Span destination, + ReadOnlyMemory? source, + int requiredLength, + string parameterName) + { + if (destination.Length != requiredLength) + throw new ArgumentException("The POD destination has the wrong length.", nameof(destination)); + if (source is null) + { + destination.Clear(); + return; + } + + var sourceSpan = source.Value.Span; + if (sourceSpan.Length != requiredLength) + throw new ArgumentException( + $"The upstream POD must contain exactly {requiredLength} bytes, got {sourceSpan.Length}.", + parameterName); + if (sourceSpan.IndexOfAnyExcept((byte)0) < 0) + throw new ArgumentException( + "The all-zero POD is reserved as the wire representation of null.", + parameterName); + sourceSpan.CopyTo(destination); + } +} diff --git a/src/SolSharp.Programs/Token2022Program.ConfidentialTransferFee.cs b/src/SolSharp.Programs/Token2022Program.ConfidentialTransferFee.cs new file mode 100644 index 0000000..487b23d --- /dev/null +++ b/src/SolSharp.Programs/Token2022Program.ConfidentialTransferFee.cs @@ -0,0 +1,170 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +public static partial class Token2022Program +{ + private const byte ConfidentialTransferFeeExtensionDiscriminator = 37; + + /// Initializes confidential transfer-fee configuration on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The configuration authority, or null for immutable configuration. + /// The exact 32-byte withdraw-authority ElGamal public-key POD. + /// The initialize-confidential-transfer-fee-config instruction. + public static Instruction InitializeConfidentialTransferFeeConfig( + PublicKey mint, + PublicKey? authority, + ReadOnlySpan withdrawAuthorityElGamalPublicKey) + { + var data = ConfidentialFeeData(innerDiscriminator: 0, payloadLength: PublicKey.Length + ElGamalPublicKeyLength); + WriteMaybeNullPublicKey(data.AsSpan(2), authority, nameof(authority)); + CopyExactPod( + data.AsSpan(2 + PublicKey.Length, ElGamalPublicKeyLength), + withdrawAuthorityElGamalPublicKey, + nameof(withdrawAuthorityElGamalPublicKey)); + return new Instruction { ProgramId = ProgramId, Accounts = [AccountMeta.Writable(mint)], Data = data }; + } + + /// Withdraws confidential fees held by a mint into a confidential token account. + /// The writable mint. + /// The writable confidential fee-receiver token account. + /// The exact 36-byte receiver balance after withdrawal. + /// The withdraw-withheld authority or multisig authority. + /// The ciphertext-ciphertext equality proof location. + /// Multisig member signers, or null/empty for a direct authority. + /// The confidential withdraw-withheld-from-mint instruction. + public static Instruction WithdrawConfidentialWithheldTokensFromMint( + PublicKey mint, + PublicKey destination, + ReadOnlySpan newDecryptableAvailableBalance, + PublicKey authority, + ConfidentialProofLocation proofLocation, + IReadOnlyList? multisigSigners = null) + { + var accounts = new List { AccountMeta.Writable(mint), AccountMeta.Writable(destination) }; + var offset = AppendConfidentialProofLocations(accounts, proofLocation)[0]; + AppendAuthority(accounts, authority, multisigSigners); + var data = ConfidentialFeeData(innerDiscriminator: 1, payloadLength: 1 + DecryptableBalanceLength); + data[2] = unchecked((byte)offset); + CopyExactPod( + data.AsSpan(3, DecryptableBalanceLength), + newDecryptableAvailableBalance, + nameof(newDecryptableAvailableBalance)); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } + + /// + /// Withdraws confidential fees directly from token accounts. This mirrors the upstream stable builder, + /// including its front-running sensitivity; harvesting to the mint first is generally preferable. + /// + /// The writable mint. + /// The writable confidential fee-receiver token account. + /// The exact 36-byte receiver balance after withdrawal. + /// The withdraw-withheld authority or multisig authority. + /// The writable source token accounts. + /// The ciphertext-ciphertext equality proof location. + /// Multisig member signers, or null/empty for a direct authority. + /// The confidential withdraw-withheld-from-accounts instruction. + public static Instruction WithdrawConfidentialWithheldTokensFromAccounts( + PublicKey mint, + PublicKey destination, + ReadOnlySpan newDecryptableAvailableBalance, + PublicKey authority, + IReadOnlyList sources, + ConfidentialProofLocation proofLocation, + IReadOnlyList? multisigSigners = null) + { + ArgumentNullException.ThrowIfNull(sources); + if (sources.Count > byte.MaxValue) + throw new ArgumentException("At most 255 confidential fee source accounts fit in this instruction.", nameof(sources)); + + var accounts = new List(4 + sources.Count + (multisigSigners?.Count ?? 0)) + { + AccountMeta.Writable(mint), + AccountMeta.Writable(destination) + }; + var offset = AppendConfidentialProofLocations(accounts, proofLocation)[0]; + AppendAuthority(accounts, authority, multisigSigners); + for (var i = 0; i < sources.Count; i++) + accounts.Add(AccountMeta.Writable(sources[i])); + + var data = ConfidentialFeeData(innerDiscriminator: 2, payloadLength: 2 + DecryptableBalanceLength); + data[2] = checked((byte)sources.Count); + data[3] = unchecked((byte)offset); + CopyExactPod( + data.AsSpan(4, DecryptableBalanceLength), + newDecryptableAvailableBalance, + nameof(newDecryptableAvailableBalance)); + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } + + /// Permissionlessly harvests confidential withheld fees from token accounts into their mint. + /// The writable mint. + /// The writable source token accounts. + /// The confidential harvest-withheld-to-mint instruction. + /// is . + public static Instruction HarvestConfidentialWithheldTokensToMint( + PublicKey mint, + IReadOnlyList sources) + { + ArgumentNullException.ThrowIfNull(sources); + var accounts = new AccountMeta[1 + sources.Count]; + accounts[0] = AccountMeta.Writable(mint); + for (var i = 0; i < sources.Count; i++) + accounts[i + 1] = AccountMeta.Writable(sources[i]); + return new Instruction + { + ProgramId = ProgramId, + Accounts = accounts, + Data = ConfidentialFeeData(innerDiscriminator: 3) + }; + } + + /// Enables accepting confidential fees harvested into a mint. + /// The writable mint. + /// The mint authority or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The enable-confidential-harvest instruction. + /// contains more than 11 accounts. + public static Instruction EnableConfidentialHarvestToMint( + PublicKey mint, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + => ConfidentialFeeAuthorityInstruction(mint, authority, innerDiscriminator: 4, multisigSigners); + + /// Disables accepting confidential fees harvested into a mint. + /// The writable mint. + /// The mint authority or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The disable-confidential-harvest instruction. + /// contains more than 11 accounts. + public static Instruction DisableConfidentialHarvestToMint( + PublicKey mint, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + => ConfidentialFeeAuthorityInstruction(mint, authority, innerDiscriminator: 5, multisigSigners); + + private static Instruction ConfidentialFeeAuthorityInstruction( + PublicKey mint, + PublicKey authority, + byte innerDiscriminator, + IReadOnlyList? multisigSigners) + { + var accounts = new List { AccountMeta.Writable(mint) }; + AppendAuthority(accounts, authority, multisigSigners); + return new Instruction + { + ProgramId = ProgramId, + Accounts = accounts, + Data = ConfidentialFeeData(innerDiscriminator) + }; + } + + private static byte[] ConfidentialFeeData(byte innerDiscriminator, int payloadLength = 0) + { + var data = new byte[2 + payloadLength]; + data[0] = ConfidentialTransferFeeExtensionDiscriminator; + data[1] = innerDiscriminator; + return data; + } +} diff --git a/src/SolSharp.Programs/Token2022Program.Metadata.cs b/src/SolSharp.Programs/Token2022Program.Metadata.cs new file mode 100644 index 0000000..10e13c9 --- /dev/null +++ b/src/SolSharp.Programs/Token2022Program.Metadata.cs @@ -0,0 +1,181 @@ +using SolSharp.Core.Encoding; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +public static partial class Token2022Program +{ + private static readonly byte[] InitializeMetadataDiscriminator = [0xd2, 0xe1, 0x1e, 0xa2, 0x58, 0xb8, 0x4d, 0x8d]; + private static readonly byte[] UpdateMetadataFieldDiscriminator = [0xdd, 0xe9, 0x31, 0x2d, 0xb5, 0xca, 0xdc, 0xc8]; + private static readonly byte[] RemoveMetadataKeyDiscriminator = [0xea, 0x12, 0x20, 0x38, 0x59, 0x8d, 0x25, 0xb5]; + private static readonly byte[] UpdateMetadataAuthorityDiscriminator = [0xd7, 0xe4, 0xa6, 0xe4, 0x54, 0x64, 0x56, 0x7b]; + private static readonly byte[] EmitMetadataDiscriminator = [0xfa, 0xa6, 0xb4, 0xfa, 0x0d, 0x0c, 0xb8, 0x46]; + + /// Initializes token metadata through Token-2022's SPL token-metadata interface implementation. + /// The writable metadata account; use the mint for in-mint metadata. + /// The readonly authority allowed to update metadata. + /// The readonly mint the metadata describes. + /// The current mint authority; signs. + /// The token's display name. + /// The token's short symbol. + /// The URI of the token's off-chain metadata. + /// The token-metadata initialize instruction. + /// A string argument is null. + /// A string contains invalid Unicode text. + public static Instruction InitializeTokenMetadata( + PublicKey metadata, + PublicKey updateAuthority, + PublicKey mint, + PublicKey mintAuthority, + string name, + string symbol, + string uri) + { + var writer = MetadataWriter(InitializeMetadataDiscriminator); + writer.WriteString(name); + writer.WriteString(symbol); + writer.WriteString(uri); + return new Instruction + { + ProgramId = ProgramId, + Accounts = + [ + AccountMeta.Writable(metadata), + AccountMeta.Readonly(updateAuthority), + AccountMeta.Readonly(mint), + AccountMeta.ReadonlySigner(mintAuthority) + ], + Data = writer.ToArray() + }; + } + + /// Updates a required field in Token-2022 token metadata. + /// The writable metadata account. + /// The current update authority; signs. + /// The required field to update. + /// The new field value. + /// The token-metadata update-field instruction. + /// is null. + /// contains invalid Unicode text. + /// is not a defined required field. + public static Instruction UpdateTokenMetadataField( + PublicKey metadata, + PublicKey updateAuthority, + TokenMetadataField field, + string value) + { + if ((byte)field > (byte)TokenMetadataField.Uri) + throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown required token-metadata field."); + return UpdateTokenMetadataFieldCore(metadata, updateAuthority, (byte)field, customKey: null, value); + } + + /// Creates or updates a custom key/value field in Token-2022 token metadata. + /// The writable metadata account. + /// The current update authority; signs. + /// The custom field key. + /// The new field value. + /// The token-metadata update-field instruction. + /// or is null. + /// A string contains invalid Unicode text. + public static Instruction UpdateTokenMetadataField( + PublicKey metadata, + PublicKey updateAuthority, + string key, + string value) + => UpdateTokenMetadataFieldCore(metadata, updateAuthority, fieldTag: 3, key, value); + + /// Removes a custom key/value field from Token-2022 token metadata. + /// The writable metadata account. + /// The current update authority; signs. + /// The custom field key to remove. + /// Whether a missing key should be treated as success. + /// The token-metadata remove-key instruction. + /// is null. + /// contains invalid Unicode text. + public static Instruction RemoveTokenMetadataKey( + PublicKey metadata, + PublicKey updateAuthority, + string key, + bool idempotent = false) + { + var writer = MetadataWriter(RemoveMetadataKeyDiscriminator); + writer.WriteBool(idempotent); + writer.WriteString(key); + return MetadataAuthorityInstruction(metadata, updateAuthority, writer.ToArray()); + } + + /// Changes or permanently removes the Token-2022 token-metadata update authority. + /// The writable metadata account. + /// The current update authority; signs. + /// The new authority, or null to make metadata immutable. + /// The token-metadata update-authority instruction. + /// is the all-zero address. + public static Instruction UpdateTokenMetadataAuthority( + PublicKey metadata, + PublicKey currentAuthority, + PublicKey? newAuthority) + { + if (newAuthority is { } authority && authority == default) + throw new ArgumentException( + "The all-zero address is reserved as the wire representation of null.", + nameof(newAuthority)); + var writer = MetadataWriter(UpdateMetadataAuthorityDiscriminator); + writer.WritePublicKey(newAuthority ?? default); + return MetadataAuthorityInstruction(metadata, currentAuthority, writer.ToArray()); + } + + /// Requests all or a byte range of token metadata through program return data. + /// The readonly metadata account. + /// The optional inclusive byte offset. + /// The optional exclusive byte offset. + /// The token-metadata emit instruction. + public static Instruction EmitTokenMetadata(PublicKey metadata, ulong? start = null, ulong? end = null) + { + var writer = MetadataWriter(EmitMetadataDiscriminator); + writer.WriteOption(start.HasValue); + if (start is { } first) + writer.WriteU64(first); + writer.WriteOption(end.HasValue); + if (end is { } last) + writer.WriteU64(last); + return new Instruction + { + ProgramId = ProgramId, + Accounts = [AccountMeta.Readonly(metadata)], + Data = writer.ToArray() + }; + } + + private static Instruction UpdateTokenMetadataFieldCore( + PublicKey metadata, + PublicKey updateAuthority, + byte fieldTag, + string? customKey, + string value) + { + var writer = MetadataWriter(UpdateMetadataFieldDiscriminator); + writer.WriteU8(fieldTag); + if (customKey is not null) + writer.WriteString(customKey); + writer.WriteString(value); + return MetadataAuthorityInstruction(metadata, updateAuthority, writer.ToArray()); + } + + private static BorshWriter MetadataWriter(ReadOnlySpan discriminator) + { + var writer = new BorshWriter(); + writer.WriteBytes(discriminator); + return writer; + } + + private static Instruction MetadataAuthorityInstruction( + PublicKey metadata, + PublicKey updateAuthority, + byte[] data) + => new() + { + ProgramId = ProgramId, + Accounts = [AccountMeta.Writable(metadata), AccountMeta.ReadonlySigner(updateAuthority)], + Data = data + }; +} diff --git a/src/SolSharp.Programs/Token2022Program.MintExtensions.cs b/src/SolSharp.Programs/Token2022Program.MintExtensions.cs new file mode 100644 index 0000000..476558f --- /dev/null +++ b/src/SolSharp.Programs/Token2022Program.MintExtensions.cs @@ -0,0 +1,440 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +public static partial class Token2022Program +{ + private const byte InterestBearingMintExtensionDiscriminator = 33; + private const byte TransferHookExtensionDiscriminator = 36; + private const byte MetadataPointerExtensionDiscriminator = 39; + private const byte GroupPointerExtensionDiscriminator = 40; + private const byte GroupMemberPointerExtensionDiscriminator = 41; + private const byte ScaledUiAmountExtensionDiscriminator = 43; + private const byte PausableExtensionDiscriminator = 44; + private const byte PermissionedBurnExtensionDiscriminator = 46; + + /// Initializes interest accrual on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The authority allowed to update the rate, or null for an immutable rate. + /// The signed annual interest rate in basis points. + /// The interest-bearing-mint initialize instruction. + /// is the all-zero address. + public static Instruction InitializeInterestBearingMint( + PublicKey mint, + PublicKey? rateAuthority, + short rateBasisPoints) + { + var data = new byte[2 + PublicKey.Length + sizeof(short)]; + data[0] = InterestBearingMintExtensionDiscriminator; + WriteMaybeNullPublicKey(data.AsSpan(2), rateAuthority, nameof(rateAuthority)); + BinaryPrimitives.WriteInt16LittleEndian(data.AsSpan(2 + PublicKey.Length), rateBasisPoints); + return MintExtensionInstruction(mint, data); + } + + /// Updates an interest-bearing Token-2022 mint's rate. + /// The writable mint. + /// The rate authority or multisig authority. + /// The new signed annual rate in basis points. + /// Multisig member signers, or null/empty for a direct authority. + /// The interest-rate update instruction. + /// contains more than 11 accounts. + public static Instruction UpdateInterestRate( + PublicKey mint, + PublicKey rateAuthority, + short rateBasisPoints, + IReadOnlyList? multisigSigners = null) + { + var data = new byte[2 + sizeof(short)]; + data[0] = InterestBearingMintExtensionDiscriminator; + data[1] = 1; + BinaryPrimitives.WriteInt16LittleEndian(data.AsSpan(2), rateBasisPoints); + return AuthorityExtensionInstruction(mint, rateAuthority, data, multisigSigners); + } + + /// Initializes a transfer-hook program pointer on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The pointer authority, or null for an immutable pointer. + /// The program invoked during transfers, or null to disable the hook. + /// The transfer-hook initialize instruction. + /// An optional address is the all-zero address. + public static Instruction InitializeTransferHook( + PublicKey mint, + PublicKey? authority, + PublicKey? transferHookProgramId) + => InitializePointerExtension( + mint, + TransferHookExtensionDiscriminator, + authority, + nameof(authority), + transferHookProgramId, + nameof(transferHookProgramId)); + + /// Updates a Token-2022 mint's transfer-hook program pointer. + /// The writable mint. + /// The pointer authority or multisig authority. + /// The new hook program, or null to disable the hook. + /// Multisig member signers, or null/empty for a direct authority. + /// The transfer-hook update instruction. + /// + /// is all-zero, or contains + /// more than 11 accounts. + /// + public static Instruction UpdateTransferHook( + PublicKey mint, + PublicKey authority, + PublicKey? transferHookProgramId, + IReadOnlyList? multisigSigners = null) + => UpdatePointerExtension( + mint, + TransferHookExtensionDiscriminator, + authority, + transferHookProgramId, + nameof(transferHookProgramId), + multisigSigners); + + /// Initializes a token-metadata pointer on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The pointer authority, or null for an immutable pointer. + /// The account holding metadata, or null when unset. + /// The metadata-pointer initialize instruction. + /// An optional address is the all-zero address. + public static Instruction InitializeMetadataPointer( + PublicKey mint, + PublicKey? authority, + PublicKey? metadataAddress) + => InitializePointerExtension( + mint, + MetadataPointerExtensionDiscriminator, + authority, + nameof(authority), + metadataAddress, + nameof(metadataAddress)); + + /// Updates a Token-2022 mint's metadata pointer. + /// The writable mint. + /// The pointer authority or multisig authority. + /// The new metadata account, or null when unset. + /// Multisig member signers, or null/empty for a direct authority. + /// The metadata-pointer update instruction. + /// + /// is all-zero, or contains more + /// than 11 accounts. + /// + public static Instruction UpdateMetadataPointer( + PublicKey mint, + PublicKey authority, + PublicKey? metadataAddress, + IReadOnlyList? multisigSigners = null) + => UpdatePointerExtension( + mint, + MetadataPointerExtensionDiscriminator, + authority, + metadataAddress, + nameof(metadataAddress), + multisigSigners); + + /// Initializes a token-group pointer on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The pointer authority, or null for an immutable pointer. + /// The account holding group configuration, or null when unset. + /// The group-pointer initialize instruction. + /// An optional address is the all-zero address. + public static Instruction InitializeGroupPointer( + PublicKey mint, + PublicKey? authority, + PublicKey? groupAddress) + => InitializePointerExtension( + mint, + GroupPointerExtensionDiscriminator, + authority, + nameof(authority), + groupAddress, + nameof(groupAddress)); + + /// Updates a Token-2022 mint's token-group pointer. + /// The writable mint. + /// The pointer authority or multisig authority. + /// The new group account, or null when unset. + /// Multisig member signers, or null/empty for a direct authority. + /// The group-pointer update instruction. + /// + /// is all-zero, or contains more than + /// 11 accounts. + /// + public static Instruction UpdateGroupPointer( + PublicKey mint, + PublicKey authority, + PublicKey? groupAddress, + IReadOnlyList? multisigSigners = null) + => UpdatePointerExtension( + mint, + GroupPointerExtensionDiscriminator, + authority, + groupAddress, + nameof(groupAddress), + multisigSigners); + + /// Initializes a token-group-member pointer on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The pointer authority, or null for an immutable pointer. + /// The account holding member configuration, or null when unset. + /// The group-member-pointer initialize instruction. + /// An optional address is the all-zero address. + public static Instruction InitializeGroupMemberPointer( + PublicKey mint, + PublicKey? authority, + PublicKey? memberAddress) + => InitializePointerExtension( + mint, + GroupMemberPointerExtensionDiscriminator, + authority, + nameof(authority), + memberAddress, + nameof(memberAddress)); + + /// Updates a Token-2022 mint's token-group-member pointer. + /// The writable mint. + /// The pointer authority or multisig authority. + /// The new member account, or null when unset. + /// Multisig member signers, or null/empty for a direct authority. + /// The group-member-pointer update instruction. + /// + /// is all-zero, or contains more than + /// 11 accounts. + /// + public static Instruction UpdateGroupMemberPointer( + PublicKey mint, + PublicKey authority, + PublicKey? memberAddress, + IReadOnlyList? multisigSigners = null) + => UpdatePointerExtension( + mint, + GroupMemberPointerExtensionDiscriminator, + authority, + memberAddress, + nameof(memberAddress), + multisigSigners); + + /// Initializes scaled UI amounts on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The multiplier authority, or null for an immutable multiplier. + /// The initial IEEE-754 multiplier. + /// The scaled-UI-amount initialize instruction. + /// is the all-zero address. + public static Instruction InitializeScaledUiAmount(PublicKey mint, PublicKey? authority, double multiplier) + { + var data = new byte[2 + PublicKey.Length + sizeof(double)]; + data[0] = ScaledUiAmountExtensionDiscriminator; + WriteMaybeNullPublicKey(data.AsSpan(2), authority, nameof(authority)); + WriteDouble(data.AsSpan(2 + PublicKey.Length), multiplier); + return MintExtensionInstruction(mint, data); + } + + /// Schedules a new scaled-UI multiplier for a Token-2022 mint. + /// The writable mint. + /// The multiplier authority or multisig authority. + /// The new IEEE-754 multiplier. + /// The Unix timestamp at which the multiplier takes effect. + /// Multisig member signers, or null/empty for a direct authority. + /// The scaled-UI-amount update instruction. + /// contains more than 11 accounts. + public static Instruction UpdateScaledUiAmount( + PublicKey mint, + PublicKey authority, + double multiplier, + long effectiveTimestamp, + IReadOnlyList? multisigSigners = null) + { + var data = new byte[2 + sizeof(double) + sizeof(long)]; + data[0] = ScaledUiAmountExtensionDiscriminator; + data[1] = 1; + WriteDouble(data.AsSpan(2), multiplier); + BinaryPrimitives.WriteInt64LittleEndian(data.AsSpan(2 + sizeof(double)), effectiveTimestamp); + return AuthorityExtensionInstruction(mint, authority, data, multisigSigners); + } + + /// Initializes the pausable extension on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The authority allowed to pause and resume the mint. + /// The pausable initialize instruction. + public static Instruction InitializePausableMint(PublicKey mint, PublicKey authority) + { + var data = new byte[2 + PublicKey.Length]; + data[0] = PausableExtensionDiscriminator; + authority.CopyTo(data.AsSpan(2)); + return MintExtensionInstruction(mint, data); + } + + /// Pauses minting, burning, and transferring for a Token-2022 mint. + /// The writable mint. + /// The pause authority or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The pause instruction. + /// contains more than 11 accounts. + public static Instruction PauseMint( + PublicKey mint, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + => AuthorityExtensionInstruction(mint, authority, [PausableExtensionDiscriminator, 1], multisigSigners); + + /// Resumes minting, burning, and transferring for a Token-2022 mint. + /// The writable mint. + /// The pause authority or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The resume instruction. + /// contains more than 11 accounts. + public static Instruction ResumeMint( + PublicKey mint, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + => AuthorityExtensionInstruction(mint, authority, [PausableExtensionDiscriminator, 2], multisigSigners); + + /// Initializes the permissioned-burn extension on a Token-2022 mint. + /// The uninitialized writable mint. + /// The authority whose signature is required for burns. + /// The permissioned-burn initialize instruction. + public static Instruction InitializePermissionedBurn(PublicKey mint, PublicKey authority) + { + var data = new byte[2 + PublicKey.Length]; + data[0] = PermissionedBurnExtensionDiscriminator; + authority.CopyTo(data.AsSpan(2)); + return MintExtensionInstruction(mint, data); + } + + /// Burns tokens with approval from a mint's permissioned-burn authority. + /// The writable token account to debit. + /// The writable mint. + /// The configured permissioned-burn signer. + /// The token account owner, delegate, or multisig authority. + /// The raw token amount to burn. + /// Multisig member signers, or null/empty for a direct owner. + /// The permissioned burn instruction. + /// contains more than 11 accounts. + public static Instruction PermissionedBurn( + PublicKey account, + PublicKey mint, + PublicKey permissionedBurnAuthority, + PublicKey owner, + ulong amount, + IReadOnlyList? multisigSigners = null) + => PermissionedBurnCore( + account, + mint, + permissionedBurnAuthority, + owner, + amount, + decimals: null, + multisigSigners); + + /// Burns tokens with permissioned approval while checking the mint's decimals. + /// The writable token account to debit. + /// The writable mint. + /// The configured permissioned-burn signer. + /// The token account owner, delegate, or multisig authority. + /// The raw token amount to burn. + /// The expected mint decimals. + /// Multisig member signers, or null/empty for a direct owner. + /// The checked permissioned burn instruction. + /// contains more than 11 accounts. + public static Instruction PermissionedBurnChecked( + PublicKey account, + PublicKey mint, + PublicKey permissionedBurnAuthority, + PublicKey owner, + ulong amount, + byte decimals, + IReadOnlyList? multisigSigners = null) + => PermissionedBurnCore( + account, + mint, + permissionedBurnAuthority, + owner, + amount, + decimals, + multisigSigners); + + private static Instruction InitializePointerExtension( + PublicKey mint, + byte outerDiscriminator, + PublicKey? authority, + string authorityParameterName, + PublicKey? address, + string addressParameterName) + { + var data = new byte[2 + (PublicKey.Length * 2)]; + data[0] = outerDiscriminator; + WriteMaybeNullPublicKey(data.AsSpan(2), authority, authorityParameterName); + WriteMaybeNullPublicKey(data.AsSpan(2 + PublicKey.Length), address, addressParameterName); + return MintExtensionInstruction(mint, data); + } + + private static Instruction UpdatePointerExtension( + PublicKey mint, + byte outerDiscriminator, + PublicKey authority, + PublicKey? address, + string addressParameterName, + IReadOnlyList? multisigSigners) + { + var data = new byte[2 + PublicKey.Length]; + data[0] = outerDiscriminator; + data[1] = 1; + WriteMaybeNullPublicKey(data.AsSpan(2), address, addressParameterName); + return AuthorityExtensionInstruction(mint, authority, data, multisigSigners); + } + + private static Instruction PermissionedBurnCore( + PublicKey account, + PublicKey mint, + PublicKey permissionedBurnAuthority, + PublicKey owner, + ulong amount, + byte? decimals, + IReadOnlyList? multisigSigners) + { + var data = new byte[2 + sizeof(ulong) + (decimals is null ? 0 : 1)]; + data[0] = PermissionedBurnExtensionDiscriminator; + data[1] = decimals is null ? (byte)1 : (byte)2; + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(2), amount); + if (decimals is { } decimalCount) + data[^1] = decimalCount; + + var instruction = new Instruction + { + ProgramId = ProgramId, + Accounts = + [ + AccountMeta.Writable(account), + AccountMeta.Writable(mint), + AccountMeta.ReadonlySigner(permissionedBurnAuthority), + AccountMeta.ReadonlySigner(owner) + ], + Data = data + }; + return multisigSigners is { Count: > 0 } + ? WithMultisigAuthority(instruction, authorityIndex: 3, multisigSigners) + : instruction; + } + + private static Instruction MintExtensionInstruction(PublicKey mint, byte[] data) + => new() { ProgramId = ProgramId, Accounts = [AccountMeta.Writable(mint)], Data = data }; + + private static void WriteMaybeNullPublicKey(Span destination, PublicKey? publicKey, string parameterName) + { + if (publicKey is null) + { + destination[..PublicKey.Length].Clear(); + return; + } + + if (publicKey.Value == default) + throw new ArgumentException( + "The all-zero address is reserved as the wire representation of null for this extension.", + parameterName); + publicKey.Value.CopyTo(destination); + } + + private static void WriteDouble(Span destination, double value) + => BinaryPrimitives.WriteInt64LittleEndian(destination, BitConverter.DoubleToInt64Bits(value)); +} diff --git a/src/SolSharp.Programs/Token2022Program.PermissionedBurn.cs b/src/SolSharp.Programs/Token2022Program.PermissionedBurn.cs new file mode 100644 index 0000000..3ce80d2 --- /dev/null +++ b/src/SolSharp.Programs/Token2022Program.PermissionedBurn.cs @@ -0,0 +1,76 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +public static partial class Token2022Program +{ + private const byte PermissionedConfidentialBurnInstructionDiscriminator = 3; + + /// + /// Burns tokens from a confidential balance with approval from the mint's permissioned-burn authority, + /// using caller-generated ciphertexts and precomputed split proofs. + /// + /// The writable token account whose confidential balance is debited. + /// The writable Token-2022 mint. + /// The configured permissioned-burn signer. + /// + /// The exact 36-byte post-burn authenticated-encryption ciphertext for the available balance. + /// + /// The exact 64-byte low burn-amount auditor ciphertext. + /// The exact 64-byte high burn-amount auditor ciphertext. + /// The token-account owner, delegate, or multisig authority. + /// The ciphertext-commitment equality-proof location. + /// + /// The batched three-handle ciphertext-validity-proof location. + /// + /// The batched U128 range-proof location. + /// Multisig member signers, or null/empty for a direct owner. + /// + /// The permissioned confidential-burn instruction. The caller composes any referenced proof-verification + /// instructions separately. + /// + /// A proof location is null. + /// + /// A ciphertext POD has an invalid length, or contains more than 11 accounts. + /// + public static Instruction BurnPermissionedConfidentialTokens( + PublicKey tokenAccount, + PublicKey mint, + PublicKey permissionedBurnAuthority, + ReadOnlySpan newDecryptableAvailableBalance, + ReadOnlySpan auditorCiphertextLow, + ReadOnlySpan auditorCiphertextHigh, + PublicKey owner, + ConfidentialProofLocation equalityProofLocation, + ConfidentialProofLocation ciphertextValidityProofLocation, + ConfidentialProofLocation rangeProofLocation, + IReadOnlyList? multisigSigners = null) + { + var accounts = new List { AccountMeta.Writable(tokenAccount), AccountMeta.Writable(mint) }; + var offsets = AppendConfidentialProofLocations( + accounts, + equalityProofLocation, + ciphertextValidityProofLocation, + rangeProofLocation); + accounts.Add(AccountMeta.ReadonlySigner(permissionedBurnAuthority)); + AppendAuthority(accounts, owner, multisigSigners); + + var data = new byte[2 + DecryptableBalanceLength + (2 * ElGamalCiphertextLength) + offsets.Length]; + data[0] = PermissionedBurnExtensionDiscriminator; + data[1] = PermissionedConfidentialBurnInstructionDiscriminator; + var cursor = 2; + CopyExactPod( + data.AsSpan(cursor, DecryptableBalanceLength), + newDecryptableAvailableBalance, + nameof(newDecryptableAvailableBalance)); + cursor += DecryptableBalanceLength; + CopyExactPod(data.AsSpan(cursor, ElGamalCiphertextLength), auditorCiphertextLow, nameof(auditorCiphertextLow)); + cursor += ElGamalCiphertextLength; + CopyExactPod(data.AsSpan(cursor, ElGamalCiphertextLength), auditorCiphertextHigh, nameof(auditorCiphertextHigh)); + cursor += ElGamalCiphertextLength; + for (var i = 0; i < offsets.Length; i++) + data[cursor + i] = unchecked((byte)offsets[i]); + + return new Instruction { ProgramId = ProgramId, Accounts = accounts, Data = data }; + } +} diff --git a/src/SolSharp.Programs/Token2022Program.TokenGroup.cs b/src/SolSharp.Programs/Token2022Program.TokenGroup.cs new file mode 100644 index 0000000..55d68e4 --- /dev/null +++ b/src/SolSharp.Programs/Token2022Program.TokenGroup.cs @@ -0,0 +1,124 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +public static partial class Token2022Program +{ + private static readonly byte[] InitializeTokenGroupDiscriminator = [0x79, 0x71, 0x6c, 0x27, 0x36, 0x33, 0x00, 0x04]; + private static readonly byte[] UpdateTokenGroupMaxSizeDiscriminator = [0x6c, 0x25, 0xab, 0x8f, 0xf8, 0x1e, 0x12, 0x6e]; + private static readonly byte[] UpdateTokenGroupAuthorityDiscriminator = [0xa1, 0x69, 0x58, 0x01, 0xed, 0xdd, 0xd8, 0xcb]; + private static readonly byte[] InitializeTokenGroupMemberDiscriminator = [0x98, 0x20, 0xde, 0xb0, 0xdf, 0xed, 0x74, 0x86]; + + /// Initializes an SPL token-group entry, normally in a Token-2022 mint. + /// The writable account that stores the group entry. + /// The mint represented by the group entry. + /// The mint authority; signs. + /// The authority allowed to update the group, or null for an immutable group. + /// The maximum number of members. + /// The program implementing the token-group interface; defaults to Token-2022. + /// The initialize-token-group instruction. + /// is the all-zero address. + public static Instruction InitializeTokenGroup( + PublicKey group, + PublicKey mint, + PublicKey mintAuthority, + PublicKey? updateAuthority, + ulong maximumSize, + PublicKey? programId = null) + { + var data = new byte[8 + PublicKey.Length + sizeof(ulong)]; + InitializeTokenGroupDiscriminator.CopyTo(data, 0); + WriteMaybeNullPublicKey(data.AsSpan(8), updateAuthority, nameof(updateAuthority)); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(8 + PublicKey.Length), maximumSize); + return new Instruction + { + ProgramId = programId ?? ProgramId, + Accounts = + [ + AccountMeta.Writable(group), + AccountMeta.Readonly(mint), + AccountMeta.ReadonlySigner(mintAuthority) + ], + Data = data + }; + } + + /// Updates the maximum number of members in an SPL token group. + /// The writable group account. + /// The current group update authority; signs. + /// The new maximum number of members. + /// The program implementing the token-group interface; defaults to Token-2022. + /// The update-group-max-size instruction. + public static Instruction UpdateTokenGroupMaxSize( + PublicKey group, + PublicKey updateAuthority, + ulong maximumSize, + PublicKey? programId = null) + { + var data = new byte[8 + sizeof(ulong)]; + UpdateTokenGroupMaxSizeDiscriminator.CopyTo(data, 0); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(8), maximumSize); + return TokenGroupAuthorityInstruction(group, updateAuthority, data, programId); + } + + /// Changes or permanently removes an SPL token group's update authority. + /// The writable group account. + /// The current group update authority; signs. + /// The new authority, or null to make the group immutable. + /// The program implementing the token-group interface; defaults to Token-2022. + /// The update-group-authority instruction. + /// is the all-zero address. + public static Instruction UpdateTokenGroupAuthority( + PublicKey group, + PublicKey currentAuthority, + PublicKey? newAuthority, + PublicKey? programId = null) + { + var data = new byte[8 + PublicKey.Length]; + UpdateTokenGroupAuthorityDiscriminator.CopyTo(data, 0); + WriteMaybeNullPublicKey(data.AsSpan(8), newAuthority, nameof(newAuthority)); + return TokenGroupAuthorityInstruction(group, currentAuthority, data, programId); + } + + /// Initializes an SPL token-group-member entry and adds it to a group. + /// The writable account that stores the member entry. + /// The mint represented by the member entry. + /// The member mint authority; signs. + /// The writable group account. + /// The group update authority; signs. + /// The program implementing the token-group interface; defaults to Token-2022. + /// The initialize-token-group-member instruction. + public static Instruction InitializeTokenGroupMember( + PublicKey member, + PublicKey memberMint, + PublicKey memberMintAuthority, + PublicKey group, + PublicKey groupUpdateAuthority, + PublicKey? programId = null) + => new() + { + ProgramId = programId ?? ProgramId, + Accounts = + [ + AccountMeta.Writable(member), + AccountMeta.Readonly(memberMint), + AccountMeta.ReadonlySigner(memberMintAuthority), + AccountMeta.Writable(group), + AccountMeta.ReadonlySigner(groupUpdateAuthority) + ], + Data = [.. InitializeTokenGroupMemberDiscriminator] + }; + + private static Instruction TokenGroupAuthorityInstruction( + PublicKey group, + PublicKey updateAuthority, + byte[] data, + PublicKey? programId) + => new() + { + ProgramId = programId ?? ProgramId, + Accounts = [AccountMeta.Writable(group), AccountMeta.ReadonlySigner(updateAuthority)], + Data = data + }; +} diff --git a/src/SolSharp.Programs/Token2022Program.TransferFee.cs b/src/SolSharp.Programs/Token2022Program.TransferFee.cs new file mode 100644 index 0000000..cf3e25c --- /dev/null +++ b/src/SolSharp.Programs/Token2022Program.TransferFee.cs @@ -0,0 +1,216 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +public static partial class Token2022Program +{ + private const byte TransferFeeExtensionDiscriminator = 26; + + /// Initializes transfer-fee configuration on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The authority allowed to update fees, or null for immutable fees. + /// The authority allowed to withdraw withheld fees, or null. + /// The transfer fee in basis points. + /// The maximum fee in raw token units. + /// The transfer-fee-config initialize instruction. + public static Instruction InitializeTransferFeeConfig( + PublicKey mint, + PublicKey? transferFeeConfigAuthority, + PublicKey? withdrawWithheldAuthority, + ushort basisPoints, + ulong maximumFee) + { + var data = new List { TransferFeeExtensionDiscriminator, 0 }; + AppendOptionalPublicKey(data, transferFeeConfigAuthority); + AppendOptionalPublicKey(data, withdrawWithheldAuthority); + Span numbers = stackalloc byte[sizeof(ushort) + sizeof(ulong)]; + BinaryPrimitives.WriteUInt16LittleEndian(numbers, basisPoints); + BinaryPrimitives.WriteUInt64LittleEndian(numbers[sizeof(ushort)..], maximumFee); + data.AddRange(numbers.ToArray()); + return MintExtensionInstruction(mint, [.. data]); + } + + /// Transfers tokens while checking the mint decimals and expected transfer fee. + /// The writable source token account. + /// The readonly mint. + /// The writable destination token account. + /// The source authority or multisig authority. + /// The raw token amount to transfer. + /// The expected mint decimals. + /// The expected fee in raw token units. + /// Multisig member signers, or null/empty for a direct authority. + /// The transferCheckedWithFee instruction. + /// contains more than 11 accounts. + public static Instruction TransferCheckedWithFee( + PublicKey source, + PublicKey mint, + PublicKey destination, + PublicKey authority, + ulong amount, + byte decimals, + ulong fee, + IReadOnlyList? multisigSigners = null) + { + var data = new byte[2 + sizeof(ulong) + sizeof(byte) + sizeof(ulong)]; + data[0] = TransferFeeExtensionDiscriminator; + data[1] = 1; + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(2), amount); + data[2 + sizeof(ulong)] = decimals; + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(3 + sizeof(ulong)), fee); + var instruction = new Instruction + { + ProgramId = ProgramId, + Accounts = + [ + AccountMeta.Writable(source), + AccountMeta.Readonly(mint), + AccountMeta.Writable(destination), + AccountMeta.ReadonlySigner(authority) + ], + Data = data + }; + return multisigSigners is { Count: > 0 } + ? WithMultisigAuthority(instruction, authorityIndex: 3, multisigSigners) + : instruction; + } + + /// Withdraws all transfer fees withheld on a Token-2022 mint to a token account. + /// The writable mint. + /// The writable fee-receiver token account. + /// The withdraw-withheld authority or multisig authority. + /// Multisig member signers, or null/empty for a direct authority. + /// The withdraw-withheld-from-mint instruction. + /// contains more than 11 accounts. + public static Instruction WithdrawWithheldTokensFromMint( + PublicKey mint, + PublicKey destination, + PublicKey authority, + IReadOnlyList? multisigSigners = null) + { + var instruction = new Instruction + { + ProgramId = ProgramId, + Accounts = [AccountMeta.Writable(mint), AccountMeta.Writable(destination), AccountMeta.ReadonlySigner(authority)], + Data = [TransferFeeExtensionDiscriminator, 2] + }; + return multisigSigners is { Count: > 0 } + ? WithMultisigAuthority(instruction, authorityIndex: 2, multisigSigners) + : instruction; + } + + /// Withdraws fees withheld across Token-2022 accounts into one destination account. + /// The readonly mint. + /// The writable fee-receiver token account. + /// The withdraw-withheld authority or multisig authority. + /// The writable source token accounts to harvest. + /// Multisig member signers, or null/empty for a direct authority. + /// The withdraw-withheld-from-accounts instruction. + /// is null. + /// + /// has more than 255 accounts, or has more + /// than 11 accounts. + /// + public static Instruction WithdrawWithheldTokensFromAccounts( + PublicKey mint, + PublicKey destination, + PublicKey authority, + IReadOnlyList sources, + IReadOnlyList? multisigSigners = null) + { + ArgumentNullException.ThrowIfNull(sources); + if (sources.Count > byte.MaxValue) + throw new ArgumentException("At most 255 source accounts fit in this instruction.", nameof(sources)); + + var accounts = new List(3 + sources.Count + (multisigSigners?.Count ?? 0)) + { + AccountMeta.Readonly(mint), + AccountMeta.Writable(destination) + }; + AppendAuthority(accounts, authority, multisigSigners); + for (var i = 0; i < sources.Count; i++) + accounts.Add(AccountMeta.Writable(sources[i])); + + return new Instruction + { + ProgramId = ProgramId, + Accounts = accounts, + Data = [TransferFeeExtensionDiscriminator, 3, (byte)sources.Count] + }; + } + + /// Permissionlessly harvests withheld fees from token accounts into their Token-2022 mint. + /// The writable mint receiving withheld fees. + /// The writable token accounts to harvest. + /// The harvest-withheld-to-mint instruction. + /// is null. + public static Instruction HarvestWithheldTokensToMint(PublicKey mint, IReadOnlyList sources) + { + ArgumentNullException.ThrowIfNull(sources); + var accounts = new AccountMeta[sources.Count + 1]; + accounts[0] = AccountMeta.Writable(mint); + for (var i = 0; i < sources.Count; i++) + accounts[i + 1] = AccountMeta.Writable(sources[i]); + return new Instruction + { + ProgramId = ProgramId, + Accounts = accounts, + Data = [TransferFeeExtensionDiscriminator, 4] + }; + } + + /// Schedules a new transfer fee for a Token-2022 mint. + /// The writable mint. + /// The transfer-fee authority or multisig authority. + /// The new transfer fee in basis points. + /// The new maximum fee in raw token units. + /// Multisig member signers, or null/empty for a direct authority. + /// The set-transfer-fee instruction. + /// contains more than 11 accounts. + public static Instruction SetTransferFee( + PublicKey mint, + PublicKey authority, + ushort basisPoints, + ulong maximumFee, + IReadOnlyList? multisigSigners = null) + { + var data = new byte[2 + sizeof(ushort) + sizeof(ulong)]; + data[0] = TransferFeeExtensionDiscriminator; + data[1] = 5; + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(2), basisPoints); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(2 + sizeof(ushort)), maximumFee); + return AuthorityExtensionInstruction(mint, authority, data, multisigSigners); + } + + private static void AppendOptionalPublicKey(List data, PublicKey? publicKey) + { + if (publicKey is null) + { + data.Add(0); + return; + } + + data.Add(1); + data.AddRange(publicKey.Value.ToBytes()); + } + + private static void AppendAuthority( + List accounts, + PublicKey authority, + IReadOnlyList? multisigSigners) + { + if (multisigSigners is not { Count: > 0 }) + { + accounts.Add(AccountMeta.ReadonlySigner(authority)); + return; + } + + if (multisigSigners.Count > MaxMultisigSigners) + throw new ArgumentException( + $"A Token-2022 multisig supports at most {MaxMultisigSigners} member signers.", + nameof(multisigSigners)); + accounts.Add(AccountMeta.Readonly(authority)); + for (var i = 0; i < multisigSigners.Count; i++) + accounts.Add(AccountMeta.ReadonlySigner(multisigSigners[i])); + } +} diff --git a/src/SolSharp.Programs/Token2022Program.cs b/src/SolSharp.Programs/Token2022Program.cs new file mode 100644 index 0000000..cb8b3ab --- /dev/null +++ b/src/SolSharp.Programs/Token2022Program.cs @@ -0,0 +1,190 @@ +using System.Buffers.Binary; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// +/// Builds Token-2022-specific instructions. Base SPL Token operations remain available through +/// by supplying as the token-program override. +/// +public static partial class Token2022Program +{ + private const byte GetAccountDataSizeDiscriminator = 21; + private const byte InitializeMintCloseAuthorityDiscriminator = 25; + private const byte ReallocateDiscriminator = 29; + private const byte CreateNativeMintDiscriminator = 31; + private const byte InitializeNonTransferableMintDiscriminator = 32; + private const byte InitializePermanentDelegateDiscriminator = 35; + private const int MaxMultisigSigners = 11; + + private static readonly PublicKey NativeMint = PublicKey.Parse(Mints.WrappedSol); + + /// The Token-2022 program address. + public static readonly PublicKey ProgramId = PublicKey.Parse(SolanaProgramIds.Token2022Program); + + /// Requests the token-account size for a mint plus additional Token-2022 extensions. + /// The mint whose existing configuration contributes required account extensions. + /// Additional extension types to include in the returned size. + /// The Token-2022 getAccountDataSize instruction. + /// is null. + /// An element is not a defined Token-2022 extension type. + public static Instruction GetAccountDataSize( + PublicKey mint, + IReadOnlyList extensionTypes) + => new() + { + ProgramId = ProgramId, + Accounts = [AccountMeta.Readonly(mint)], + Data = ExtensionTypesData(GetAccountDataSizeDiscriminator, extensionTypes) + }; + + /// Initializes the close authority extension on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The authority allowed to close the mint, or null for none. + /// The initializeMintCloseAuthority instruction. + public static Instruction InitializeMintCloseAuthority(PublicKey mint, PublicKey? closeAuthority) + => new() + { + ProgramId = ProgramId, + Accounts = [AccountMeta.Writable(mint)], + Data = OptionalPublicKeyData(InitializeMintCloseAuthorityDiscriminator, closeAuthority) + }; + + /// Reallocates a token account to make room for additional Token-2022 extensions. + /// The writable token account to resize. + /// The writable signer funding any additional rent. + /// The token account owner; signs. + /// The complete extension types to add. + /// The reallocate instruction. + /// is null. + /// An element is not a defined Token-2022 extension type. + public static Instruction Reallocate( + PublicKey account, + PublicKey payer, + PublicKey owner, + IReadOnlyList extensionTypes) + => new() + { + ProgramId = ProgramId, + Accounts = + [ + AccountMeta.Writable(account), + AccountMeta.WritableSigner(payer), + AccountMeta.Readonly(SystemProgram.ProgramId), + AccountMeta.ReadonlySigner(owner) + ], + Data = ExtensionTypesData(ReallocateDiscriminator, extensionTypes) + }; + + /// Reallocates a token account using a multisig owner. + /// The writable token account to resize. + /// The writable signer funding any additional rent. + /// The multisig owner account. + /// The complete extension types to add. + /// The multisig member accounts that sign, in account order. + /// The reallocate instruction. + /// + /// or is null. + /// + /// is empty or contains more than 11 accounts. + /// An extension element is not a defined Token-2022 type. + public static Instruction Reallocate( + PublicKey account, + PublicKey payer, + PublicKey owner, + IReadOnlyList extensionTypes, + IReadOnlyList multisigSigners) + => WithMultisigAuthority(Reallocate(account, payer, owner, extensionTypes), authorityIndex: 3, multisigSigners); + + /// Creates the Token-2022 native wrapped-SOL mint. + /// The writable system-account signer funding native-mint creation. + /// The createNativeMint instruction. + public static Instruction CreateNativeMint(PublicKey payer) + => new() + { + ProgramId = ProgramId, + Accounts = + [ + AccountMeta.WritableSigner(payer), + AccountMeta.Writable(NativeMint), + AccountMeta.Readonly(SystemProgram.ProgramId) + ], + Data = [CreateNativeMintDiscriminator] + }; + + /// Initializes the non-transferable extension on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The initializeNonTransferableMint instruction. + public static Instruction InitializeNonTransferableMint(PublicKey mint) + => WritableMintInstruction(mint, InitializeNonTransferableMintDiscriminator); + + /// Initializes the permanent delegate extension on a new Token-2022 mint. + /// The uninitialized writable mint. + /// The permanent delegate encoded in instruction data. + /// The initializePermanentDelegate instruction. + public static Instruction InitializePermanentDelegate(PublicKey mint, PublicKey @delegate) + { + var data = new byte[PublicKey.Length + 1]; + data[0] = InitializePermanentDelegateDiscriminator; + @delegate.CopyTo(data.AsSpan(1)); + return new Instruction { ProgramId = ProgramId, Accounts = [AccountMeta.Writable(mint)], Data = data }; + } + + private static Instruction WritableMintInstruction(PublicKey mint, byte discriminator) + => new() { ProgramId = ProgramId, Accounts = [AccountMeta.Writable(mint)], Data = [discriminator] }; + + private static byte[] ExtensionTypesData( + byte discriminator, + IReadOnlyList extensionTypes) + { + ArgumentNullException.ThrowIfNull(extensionTypes); + var data = new byte[1 + (extensionTypes.Count * sizeof(ushort))]; + data[0] = discriminator; + for (var i = 0; i < extensionTypes.Count; i++) + { + var extensionType = extensionTypes[i]; + if ((ushort)extensionType > (ushort)Token2022ExtensionType.PermissionedBurn) + throw new ArgumentOutOfRangeException( + nameof(extensionTypes), + extensionType, + $"Unknown Token-2022 extension type at index {i}."); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(1 + (i * sizeof(ushort))), (ushort)extensionType); + } + + return data; + } + + private static byte[] OptionalPublicKeyData(byte discriminator, PublicKey? publicKey) + { + if (publicKey is null) + return [discriminator, 0]; + + var data = new byte[PublicKey.Length + 2]; + data[0] = discriminator; + data[1] = 1; + publicKey.Value.CopyTo(data.AsSpan(2)); + return data; + } + + private static Instruction WithMultisigAuthority( + Instruction instruction, + int authorityIndex, + IReadOnlyList multisigSigners) + { + ArgumentNullException.ThrowIfNull(multisigSigners); + if (multisigSigners.Count is < 1 or > MaxMultisigSigners) + throw new ArgumentException( + $"A Token-2022 multisig requires between 1 and {MaxMultisigSigners} member signers.", + nameof(multisigSigners)); + + var accounts = new AccountMeta[instruction.Accounts.Count + multisigSigners.Count]; + for (var i = 0; i < instruction.Accounts.Count; i++) + accounts[i] = instruction.Accounts[i]; + accounts[authorityIndex] = AccountMeta.Readonly(instruction.Accounts[authorityIndex].PublicKey); + for (var i = 0; i < multisigSigners.Count; i++) + accounts[instruction.Accounts.Count + i] = AccountMeta.ReadonlySigner(multisigSigners[i]); + + return new Instruction { ProgramId = instruction.ProgramId, Accounts = accounts, Data = [.. instruction.Data] }; + } +} diff --git a/src/SolSharp.Programs/TokenAccountState.cs b/src/SolSharp.Programs/TokenAccountState.cs new file mode 100644 index 0000000..ee8ac10 --- /dev/null +++ b/src/SolSharp.Programs/TokenAccountState.cs @@ -0,0 +1,338 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// The initialization/freeze status stored in an SPL token account. +public enum TokenAccountStatus : byte +{ + /// The account has not been initialized. + Uninitialized = 0, + + /// The account is initialized and usable. + Initialized = 1, + + /// The account is frozen. + Frozen = 2 +} + +/// An opaque Token-2022 extension value decoded from its two-byte type and length TLV header. +public sealed class Token2022ExtensionData +{ + private readonly byte[] _data; + + internal Token2022ExtensionData(Token2022ExtensionType extensionType, ReadOnlySpan data) + { + ExtensionType = extensionType; + _data = data.ToArray(); + } + + /// The pinned Token-2022 extension type. + public Token2022ExtensionType ExtensionType { get; } + + /// The exact extension value bytes, excluding the TLV type and length fields. + public ReadOnlyMemory Data => _data; +} + +/// A decoded classic SPL Token mint base state plus optional Token-2022 TLV extensions. +public sealed class TokenMintState +{ + /// The classic mint base-state length. + public const int BaseLength = 82; + + internal TokenMintState( + PublicKey? mintAuthority, + ulong supply, + byte decimals, + bool isInitialized, + PublicKey? freezeAuthority, + IReadOnlyList extensions) + { + MintAuthority = mintAuthority; + Supply = supply; + Decimals = decimals; + IsInitialized = isInitialized; + FreezeAuthority = freezeAuthority; + Extensions = extensions; + } + + /// The mint authority, or null for fixed supply. + public PublicKey? MintAuthority { get; } + + /// The raw token supply. + public ulong Supply { get; } + + /// The number of decimal places. + public byte Decimals { get; } + + /// Whether the mint is initialized. + public bool IsInitialized { get; } + + /// The freeze authority, or null. + public PublicKey? FreezeAuthority { get; } + + /// Token-2022 extension values in TLV order; empty for classic mint data. + public IReadOnlyList Extensions { get; } + + /// Decodes classic 82-byte mint data or the Token-2022 extended-mint envelope. + /// Complete mint account data. + /// + /// The decoded state, or null when the data is malformed or uses the 355-byte multisignature length. + /// + public static TokenMintState? Decode(ReadOnlySpan data) + { + if (data.Length < BaseLength || !TokenStateDecoder.TryReadCOptionPublicKey(data, out var mintAuthority)) + return null; + var initializedByte = data[45]; + if (initializedByte > 1 || !TokenStateDecoder.TryReadCOptionPublicKey(data[46..], out var freezeAuthority)) + return null; + if (!TokenStateDecoder.TryDecodeExtensions(data, BaseLength, expectedAccountType: 1, out var extensions)) + return null; + + return new TokenMintState( + mintAuthority, + BinaryPrimitives.ReadUInt64LittleEndian(data[36..]), + data[44], + initializedByte != 0, + freezeAuthority, + extensions); + } +} + +/// A decoded classic SPL Token holding-account base state plus optional Token-2022 TLV extensions. +public sealed class TokenHoldingAccountState +{ + /// The classic token-account base-state length. + public const int BaseLength = 165; + + internal TokenHoldingAccountState( + PublicKey mint, + PublicKey owner, + ulong amount, + PublicKey? @delegate, + TokenAccountStatus status, + ulong? nativeRentExemptReserve, + ulong delegatedAmount, + PublicKey? closeAuthority, + IReadOnlyList extensions) + { + Mint = mint; + Owner = owner; + Amount = amount; + Delegate = @delegate; + Status = status; + NativeRentExemptReserve = nativeRentExemptReserve; + DelegatedAmount = delegatedAmount; + CloseAuthority = closeAuthority; + Extensions = extensions; + } + + /// The associated mint. + public PublicKey Mint { get; } + + /// The account owner. + public PublicKey Owner { get; } + + /// The raw token balance. + public ulong Amount { get; } + + /// The approved delegate, or null. + public PublicKey? Delegate { get; } + + /// The account status. + public TokenAccountStatus Status { get; } + + /// The wrapped-native rent-exempt reserve, or null for a non-native account. + public ulong? NativeRentExemptReserve { get; } + + /// The remaining amount delegated to . + public ulong DelegatedAmount { get; } + + /// The close authority, or null. + public PublicKey? CloseAuthority { get; } + + /// Token-2022 extension values in TLV order; empty for classic account data. + public IReadOnlyList Extensions { get; } + + /// Decodes classic 165-byte token-account data or the Token-2022 extended-account envelope. + /// Complete token account data. + /// + /// The decoded state, or null when the data is malformed or uses the 355-byte multisignature length. + /// + public static TokenHoldingAccountState? Decode(ReadOnlySpan data) + { + if (data.Length < BaseLength || !TokenStateDecoder.TryReadCOptionPublicKey(data[72..], out var @delegate)) + return null; + var statusByte = data[108]; + if (statusByte > (byte)TokenAccountStatus.Frozen || + !TokenStateDecoder.TryReadCOptionUInt64(data[109..], out var nativeReserve) || + !TokenStateDecoder.TryReadCOptionPublicKey(data[129..], out var closeAuthority) || + !TokenStateDecoder.TryDecodeExtensions(data, BaseLength, expectedAccountType: 2, out var extensions)) + { + return null; + } + + return new TokenHoldingAccountState( + new PublicKey(data[..PublicKey.Length]), + new PublicKey(data.Slice(PublicKey.Length, PublicKey.Length)), + BinaryPrimitives.ReadUInt64LittleEndian(data[64..]), + @delegate, + (TokenAccountStatus)statusByte, + nativeReserve, + BinaryPrimitives.ReadUInt64LittleEndian(data[121..]), + closeAuthority, + extensions); + } +} + +/// A decoded classic SPL Token multisignature account. +public sealed class TokenMultisigState +{ + /// The fixed multisig account length. + public const int Length = 355; + + internal TokenMultisigState(byte requiredSignatures, byte signerCount, bool isInitialized, PublicKey[] signers) + { + RequiredSignatures = requiredSignatures; + SignerCount = signerCount; + IsInitialized = isInitialized; + Signers = signers; + } + + /// The number of required member signatures. + public byte RequiredSignatures { get; } + + /// The number of configured signer slots. + public byte SignerCount { get; } + + /// Whether the multisig is initialized. + public bool IsInitialized { get; } + + /// All eleven encoded signer slots, matching the upstream state layout. + public IReadOnlyList Signers { get; } + + /// Decodes an exact 355-byte classic or Token-2022 multisig state. + /// Complete multisig data. + /// The decoded state, or null when the length or boolean is invalid. + public static TokenMultisigState? Decode(ReadOnlySpan data) + { + if (data.Length != Length || data[2] > 1) + return null; + var signers = new PublicKey[11]; + for (var i = 0; i < signers.Length; i++) + signers[i] = new PublicKey(data.Slice(3 + (i * PublicKey.Length), PublicKey.Length)); + return new TokenMultisigState(data[0], data[1], data[2] != 0, signers); + } +} + +internal static class TokenStateDecoder +{ + private const int ExtendedBaseLength = TokenHoldingAccountState.BaseLength; + + public static bool TryReadCOptionPublicKey(ReadOnlySpan data, out PublicKey? publicKey) + { + if (data.Length < sizeof(uint) + PublicKey.Length) + { + publicKey = null; + return false; + } + + var tag = BinaryPrimitives.ReadUInt32LittleEndian(data); + if (tag == 0) + { + publicKey = null; + return true; + } + + if (tag == 1) + { + publicKey = new PublicKey(data.Slice(sizeof(uint), PublicKey.Length)); + return true; + } + + publicKey = null; + return false; + } + + public static bool TryReadCOptionUInt64(ReadOnlySpan data, out ulong? value) + { + if (data.Length < sizeof(uint) + sizeof(ulong)) + { + value = null; + return false; + } + + var tag = BinaryPrimitives.ReadUInt32LittleEndian(data); + if (tag == 0) + { + value = null; + return true; + } + + if (tag == 1) + { + value = BinaryPrimitives.ReadUInt64LittleEndian(data[sizeof(uint)..]); + return true; + } + + value = null; + return false; + } + + public static bool TryDecodeExtensions( + ReadOnlySpan data, + int baseLength, + byte expectedAccountType, + out IReadOnlyList extensions) + { + if (data.Length == baseLength) + { + extensions = []; + return true; + } + + var paddingLength = ExtendedBaseLength - baseLength; + if (data.Length == TokenMultisigState.Length || + data.Length <= ExtendedBaseLength || + data.Slice(baseLength, paddingLength).IndexOfAnyExcept((byte)0) >= 0 || + data[ExtendedBaseLength] != expectedAccountType) + { + extensions = []; + return false; + } + + var decoded = new List(); + var tlv = data[(ExtendedBaseLength + 1)..]; + var offset = 0; + while (offset < tlv.Length) + { + if (tlv.Length - offset < sizeof(ushort)) + break; + var typeValue = BinaryPrimitives.ReadUInt16LittleEndian(tlv[offset..]); + if (typeValue == 0) + break; + if (typeValue > (ushort)Token2022ExtensionType.PermissionedBurn || + tlv.Length - offset < 2 * sizeof(ushort)) + { + extensions = []; + return false; + } + + var valueLength = BinaryPrimitives.ReadUInt16LittleEndian(tlv[(offset + sizeof(ushort))..]); + var valueStart = offset + (2 * sizeof(ushort)); + if (valueLength > tlv.Length - valueStart) + { + extensions = []; + return false; + } + + decoded.Add(new Token2022ExtensionData( + (Token2022ExtensionType)typeValue, + tlv.Slice(valueStart, valueLength))); + offset = valueStart + valueLength; + } + + extensions = decoded; + return true; + } +} diff --git a/src/SolSharp.Programs/TokenGroupState.cs b/src/SolSharp.Programs/TokenGroupState.cs new file mode 100644 index 0000000..8e4dd21 --- /dev/null +++ b/src/SolSharp.Programs/TokenGroupState.cs @@ -0,0 +1,73 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// The fixed-size value stored by the SPL token-group interface for a group. +public sealed record TokenGroupState +{ + /// The serialized value length, excluding its surrounding Token-2022 or generic TLV header. + public const int Length = (PublicKey.Length * 2) + (sizeof(ulong) * 2); + + /// The authority allowed to update the group, or null when the group is immutable. + public required PublicKey? UpdateAuthority { get; init; } + + /// The mint represented by the group. + public required PublicKey Mint { get; init; } + + /// The current number of members. + public required ulong Size { get; init; } + + /// The maximum number of members. + public required ulong MaximumSize { get; init; } + + /// Decodes an exact SPL token-group value. + /// The value bytes, without a TLV header. + /// The decoded group, or null when does not have the exact layout length. + public static TokenGroupState? Decode(ReadOnlySpan data) + { + if (data.Length != Length) + return null; + + var authority = new PublicKey(data[..PublicKey.Length]); + return new TokenGroupState + { + UpdateAuthority = authority == default ? null : authority, + Mint = new PublicKey(data.Slice(PublicKey.Length, PublicKey.Length)), + Size = BinaryPrimitives.ReadUInt64LittleEndian(data[(PublicKey.Length * 2)..]), + MaximumSize = BinaryPrimitives.ReadUInt64LittleEndian(data[((PublicKey.Length * 2) + sizeof(ulong))..]) + }; + } +} + +/// The fixed-size value stored by the SPL token-group interface for a group member. +public sealed record TokenGroupMemberState +{ + /// The serialized value length, excluding its surrounding Token-2022 or generic TLV header. + public const int Length = (PublicKey.Length * 2) + sizeof(ulong); + + /// The mint represented by this member entry. + public required PublicKey Mint { get; init; } + + /// The group to which this member belongs. + public required PublicKey Group { get; init; } + + /// The member's one-based sequence number within the group. + public required ulong MemberNumber { get; init; } + + /// Decodes an exact SPL token-group-member value. + /// The value bytes, without a TLV header. + /// The decoded member, or null when does not have the exact layout length. + public static TokenGroupMemberState? Decode(ReadOnlySpan data) + { + if (data.Length != Length) + return null; + + return new TokenGroupMemberState + { + Mint = new PublicKey(data[..PublicKey.Length]), + Group = new PublicKey(data.Slice(PublicKey.Length, PublicKey.Length)), + MemberNumber = BinaryPrimitives.ReadUInt64LittleEndian(data[(PublicKey.Length * 2)..]) + }; + } +} diff --git a/src/SolSharp.Programs/TokenInstructionDecoder.cs b/src/SolSharp.Programs/TokenInstructionDecoder.cs new file mode 100644 index 0000000..64b18d2 --- /dev/null +++ b/src/SolSharp.Programs/TokenInstructionDecoder.cs @@ -0,0 +1,406 @@ +using System.Buffers.Binary; +using System.Text; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// A single instruction embedded in Token-2022's compact batch payload. +public sealed class DecodedTokenBatchEntry +{ + private readonly byte[] _data; + + internal DecodedTokenBatchEntry(byte accountCount, ReadOnlySpan data) + { + AccountCount = accountCount; + _data = data.ToArray(); + } + + /// The number of sequential account metas consumed by this embedded instruction. + public byte AccountCount { get; } + + /// The complete embedded token instruction data. + public ReadOnlyMemory Data => _data; +} + +/// +/// A decoded SPL Token or Token-2022 instruction. Common fixed fields are exposed in typed form while +/// extension-specific POD bytes remain available through . +/// +public sealed class DecodedTokenInstruction +{ + private readonly byte[] _payload; + + internal DecodedTokenInstruction(byte discriminator, string name, ReadOnlySpan payload) + { + Discriminator = discriminator; + Name = name; + _payload = payload.ToArray(); + } + + /// The outer one-byte token instruction discriminator. + public byte Discriminator { get; } + + /// The upstream instruction variant name. + public string Name { get; } + + /// All bytes after the outer discriminator. + public ReadOnlyMemory Payload => _payload; + + /// The inner one-byte discriminator for a Token-2022 extension instruction, when present. + public byte? ExtensionInstructionDiscriminator { get; internal set; } + + /// An amount decoded from a standard token instruction. + public ulong? Amount { get; internal set; } + + /// Mint decimals decoded from a checked or initialize-mint instruction. + public byte? Decimals { get; internal set; } + + /// A primary authority, owner, or delegate key encoded in instruction data. + public PublicKey? RelatedPublicKey { get; internal set; } + + /// An optional freeze/new/close authority encoded in instruction data. + public PublicKey? OptionalPublicKey { get; internal set; } + + /// Whether this variant contains an optional-public-key field, including when its value is null. + public bool HasOptionalPublicKey { get; internal set; } + + /// The set-authority kind, when this is a SetAuthority instruction. + public AuthorityType? AuthorityType { get; internal set; } + + /// The required signature count in an initialize-multisig instruction. + public byte? RequiredSignatures { get; internal set; } + + /// The UI amount string in a UiAmountToAmount instruction. + public string? UiAmount { get; internal set; } + + /// Extension types from Token-2022 GetAccountDataSize or Reallocate data. + public IReadOnlyList ExtensionTypes { get; internal set; } = []; + + /// Whether this variant contains an optional raw amount, including when its value is null. + public bool HasOptionalAmount { get; internal set; } + + /// The decoded entries of a Token-2022 compact batch. + public IReadOnlyList BatchEntries { get; internal set; } = []; +} + +public static partial class TokenProgram +{ + private static readonly UTF8Encoding StrictInstructionUtf8 = new(false, true); + + /// Decodes SPL Token and Token-2022 instruction data using the pinned upstream wire layouts. + /// Complete instruction data, beginning with the outer discriminator. + /// The decoded instruction, or null when the discriminator or required data is invalid. + public static DecodedTokenInstruction? DecodeInstructionData(ReadOnlySpan data) + { + if (data.IsEmpty) + return null; + + var discriminator = data[0]; + var payload = data[1..]; + var decoded = new DecodedTokenInstruction(discriminator, TokenInstructionName(discriminator), payload); + switch (discriminator) + { + case 0: + case 20: + if (payload.Length < 1 + PublicKey.Length || + !TryReadCompactOptionalPublicKey(payload[(1 + PublicKey.Length)..], out var freezeAuthority)) + { + return null; + } + + decoded.Decimals = payload[0]; + decoded.RelatedPublicKey = new PublicKey(payload.Slice(1, PublicKey.Length)); + decoded.HasOptionalPublicKey = true; + decoded.OptionalPublicKey = freezeAuthority; + break; + case 1: + case 5: + case 9: + case 10: + case 11: + case 17: + case 22: + case 31: + case 32: + case 38: + break; + case 2: + case 19: + if (payload.IsEmpty) + return null; + decoded.RequiredSignatures = payload[0]; + break; + case 3: + case 4: + case 7: + case 8: + case 23: + if (!TryReadAmount(payload, out var amount)) + return null; + decoded.Amount = amount; + break; + case 6: + if (payload.IsEmpty || + payload[0] > (byte)Programs.AuthorityType.PermissionedBurn || + !TryReadCompactOptionalPublicKey(payload[1..], out var newAuthority)) + { + return null; + } + + decoded.AuthorityType = (Programs.AuthorityType)payload[0]; + decoded.HasOptionalPublicKey = true; + decoded.OptionalPublicKey = newAuthority; + break; + case 12: + case 13: + case 14: + case 15: + if (payload.Length < sizeof(ulong) + 1) + return null; + decoded.Amount = BinaryPrimitives.ReadUInt64LittleEndian(payload); + decoded.Decimals = payload[sizeof(ulong)]; + break; + case 16: + case 18: + case 35: + if (payload.Length < PublicKey.Length) + return null; + decoded.RelatedPublicKey = new PublicKey(payload[..PublicKey.Length]); + break; + case 21: + case 29: + if (!TryReadExtensionTypes(payload, out var extensionTypes)) + return null; + decoded.ExtensionTypes = extensionTypes; + break; + case 24: + try + { + decoded.UiAmount = StrictInstructionUtf8.GetString(payload); + } + catch (DecoderFallbackException) + { + return null; + } + + break; + case 25: + if (!TryReadCompactOptionalPublicKey(payload, out var closeAuthority)) + return null; + decoded.HasOptionalPublicKey = true; + decoded.OptionalPublicKey = closeAuthority; + break; + case 26: + case 27: + case 28: + case 30: + case 33: + case 34: + case 36: + case 37: + case 39: + case 40: + case 41: + case 42: + case 43: + case 44: + case 46: + if (!payload.IsEmpty) + decoded.ExtensionInstructionDiscriminator = payload[0]; + break; + case 45: + if (!TryReadCompactOptionalAmount(payload, out var optionalAmount)) + return null; + decoded.HasOptionalAmount = true; + decoded.Amount = optionalAmount; + break; + case 255: + if (!TryReadBatchEntries(payload, out var entries)) + return null; + decoded.BatchEntries = entries; + break; + default: + return null; + } + + return decoded; + } + + private static string TokenInstructionName(byte discriminator) + => discriminator switch + { + 0 => "InitializeMint", + 1 => "InitializeAccount", + 2 => "InitializeMultisig", + 3 => "Transfer", + 4 => "Approve", + 5 => "Revoke", + 6 => "SetAuthority", + 7 => "MintTo", + 8 => "Burn", + 9 => "CloseAccount", + 10 => "FreezeAccount", + 11 => "ThawAccount", + 12 => "TransferChecked", + 13 => "ApproveChecked", + 14 => "MintToChecked", + 15 => "BurnChecked", + 16 => "InitializeAccount2", + 17 => "SyncNative", + 18 => "InitializeAccount3", + 19 => "InitializeMultisig2", + 20 => "InitializeMint2", + 21 => "GetAccountDataSize", + 22 => "InitializeImmutableOwner", + 23 => "AmountToUiAmount", + 24 => "UiAmountToAmount", + 25 => "InitializeMintCloseAuthority", + 26 => "TransferFeeExtension", + 27 => "ConfidentialTransferExtension", + 28 => "DefaultAccountStateExtension", + 29 => "Reallocate", + 30 => "MemoTransferExtension", + 31 => "CreateNativeMint", + 32 => "InitializeNonTransferableMint", + 33 => "InterestBearingMintExtension", + 34 => "CpiGuardExtension", + 35 => "InitializePermanentDelegate", + 36 => "TransferHookExtension", + 37 => "ConfidentialTransferFeeExtension", + 38 => "WithdrawExcessLamports", + 39 => "MetadataPointerExtension", + 40 => "GroupPointerExtension", + 41 => "GroupMemberPointerExtension", + 42 => "ConfidentialMintBurnExtension", + 43 => "ScaledUiAmountExtension", + 44 => "PausableExtension", + 45 => "UnwrapLamports", + 46 => "PermissionedBurnExtension", + 255 => "Batch", + _ => string.Empty + }; + + private static bool TryReadAmount(ReadOnlySpan data, out ulong amount) + { + if (data.Length < sizeof(ulong)) + { + amount = 0; + return false; + } + + amount = BinaryPrimitives.ReadUInt64LittleEndian(data); + return true; + } + + private static bool TryReadCompactOptionalPublicKey(ReadOnlySpan data, out PublicKey? value) + { + if (data.IsEmpty) + { + value = null; + return false; + } + + if (data[0] == 0) + { + value = null; + return true; + } + + if (data[0] == 1 && data.Length >= 1 + PublicKey.Length) + { + value = new PublicKey(data.Slice(1, PublicKey.Length)); + return true; + } + + value = null; + return false; + } + + private static bool TryReadCompactOptionalAmount(ReadOnlySpan data, out ulong? value) + { + if (data.IsEmpty) + { + value = null; + return false; + } + + if (data[0] == 0) + { + value = null; + return true; + } + + if (data[0] == 1 && data.Length >= 1 + sizeof(ulong)) + { + value = BinaryPrimitives.ReadUInt64LittleEndian(data[1..]); + return true; + } + + value = null; + return false; + } + + private static bool TryReadExtensionTypes( + ReadOnlySpan data, + out IReadOnlyList extensionTypes) + { + if (data.Length % sizeof(ushort) != 0) + { + extensionTypes = []; + return false; + } + + var decoded = new Token2022ExtensionType[data.Length / sizeof(ushort)]; + for (var i = 0; i < decoded.Length; i++) + { + var value = BinaryPrimitives.ReadUInt16LittleEndian(data[(i * sizeof(ushort))..]); + if (value > (ushort)Token2022ExtensionType.PermissionedBurn) + { + extensionTypes = []; + return false; + } + + decoded[i] = (Token2022ExtensionType)value; + } + + extensionTypes = decoded; + return true; + } + + private static bool TryReadBatchEntries( + ReadOnlySpan data, + out IReadOnlyList entries) + { + if (data.IsEmpty) + { + entries = []; + return false; + } + + var decoded = new List(); + var offset = 0; + while (offset < data.Length) + { + if (data.Length - offset < 2) + { + entries = []; + return false; + } + + var accountCount = data[offset]; + var dataLength = data[offset + 1]; + offset += 2; + if (dataLength == 0 || dataLength > data.Length - offset) + { + entries = []; + return false; + } + + decoded.Add(new DecodedTokenBatchEntry(accountCount, data.Slice(offset, dataLength))); + offset += dataLength; + } + + entries = decoded; + return true; + } +} diff --git a/src/SolSharp.Programs/TokenMetadataField.cs b/src/SolSharp.Programs/TokenMetadataField.cs new file mode 100644 index 0000000..9a4a0b8 --- /dev/null +++ b/src/SolSharp.Programs/TokenMetadataField.cs @@ -0,0 +1,14 @@ +namespace SolSharp.Programs; + +/// A required field in the SPL token-metadata interface. +public enum TokenMetadataField : byte +{ + /// The token's display name. + Name = 0, + + /// The token's short symbol. + Symbol = 1, + + /// The URI of the token's off-chain metadata. + Uri = 2 +} diff --git a/src/SolSharp.Programs/TokenMetadataState.cs b/src/SolSharp.Programs/TokenMetadataState.cs new file mode 100644 index 0000000..d64a4ba --- /dev/null +++ b/src/SolSharp.Programs/TokenMetadataState.cs @@ -0,0 +1,111 @@ +using System.Buffers.Binary; +using System.Text; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// A decoded SPL token-metadata interface value. +public sealed class TokenMetadataState +{ + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + internal TokenMetadataState( + PublicKey? updateAuthority, + PublicKey mint, + string name, + string symbol, + string uri, + IReadOnlyList> additionalMetadata) + { + UpdateAuthority = updateAuthority; + Mint = mint; + Name = name; + Symbol = symbol; + Uri = uri; + AdditionalMetadata = additionalMetadata; + } + + /// The metadata update authority, or null for immutable metadata. + public PublicKey? UpdateAuthority { get; } + + /// The mint described by the metadata. + public PublicKey Mint { get; } + + /// The display name. + public string Name { get; } + + /// The short symbol. + public string Symbol { get; } + + /// The off-chain metadata URI. + public string Uri { get; } + + /// Additional key/value entries in encoded order. + public IReadOnlyList> AdditionalMetadata { get; } + + /// Decodes the Borsh value stored in an SPL token-metadata TLV entry. + /// The extension value without its TLV type/length header. + /// The decoded metadata, or null when the Borsh value is malformed. + public static TokenMetadataState? Decode(ReadOnlySpan data) + { + if (data.Length < PublicKey.Length * 2) + return null; + var updateBytes = data[..PublicKey.Length]; + PublicKey? updateAuthority = updateBytes.IndexOfAnyExcept((byte)0) < 0 ? null : new PublicKey(updateBytes); + var mint = new PublicKey(data.Slice(PublicKey.Length, PublicKey.Length)); + var offset = PublicKey.Length * 2; + if (!TryReadString(data, ref offset, out var name) || + !TryReadString(data, ref offset, out var symbol) || + !TryReadString(data, ref offset, out var uri) || + data.Length - offset < sizeof(uint)) + { + return null; + } + + var count = BinaryPrimitives.ReadUInt32LittleEndian(data[offset..]); + offset += sizeof(uint); + const int minimumEntryLength = sizeof(uint) * 2; + var maximumEntriesInRemainingData = (uint)((data.Length - offset) / minimumEntryLength); + if (count > int.MaxValue || count > maximumEntriesInRemainingData) + return null; + var additional = new List>((int)count); + for (var i = 0; i < count; i++) + { + if (!TryReadString(data, ref offset, out var key) || !TryReadString(data, ref offset, out var value)) + return null; + additional.Add(new KeyValuePair(key, value)); + } + + return new TokenMetadataState(updateAuthority, mint, name, symbol, uri, additional); + } + + internal static bool TryReadString(ReadOnlySpan data, ref int offset, out string value) + { + if (offset < 0 || data.Length - offset < sizeof(uint)) + { + value = string.Empty; + return false; + } + + var length = BinaryPrimitives.ReadUInt32LittleEndian(data[offset..]); + offset += sizeof(uint); + if (length > int.MaxValue || length > data.Length - offset) + { + value = string.Empty; + return false; + } + + try + { + value = StrictUtf8.GetString(data.Slice(offset, (int)length)); + } + catch (DecoderFallbackException) + { + value = string.Empty; + return false; + } + + offset += (int)length; + return true; + } +} diff --git a/src/SolSharp.Programs/TokenProgram.Parity.cs b/src/SolSharp.Programs/TokenProgram.Parity.cs new file mode 100644 index 0000000..de8e71d --- /dev/null +++ b/src/SolSharp.Programs/TokenProgram.Parity.cs @@ -0,0 +1,421 @@ +using System.Buffers.Binary; +using System.Text; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +public static partial class TokenProgram +{ + private const byte InitializeMultisigDiscriminator = 2; + private const byte InitializeAccount2Discriminator = 16; + private const byte InitializeAccount3Discriminator = 18; + private const byte InitializeMultisig2Discriminator = 19; + private const byte InitializeMint2Discriminator = 20; + private const byte GetAccountDataSizeDiscriminator = 21; + private const byte InitializeImmutableOwnerDiscriminator = 22; + private const byte AmountToUiAmountDiscriminator = 23; + private const byte UiAmountToAmountDiscriminator = 24; + private const byte WithdrawExcessLamportsDiscriminator = 38; + private const byte UnwrapLamportsDiscriminator = 45; + private const byte BatchDiscriminator = 255; + + private static readonly UTF8Encoding StrictTokenUtf8 = new(false, true); + + /// Builds the rent-sysvar-free form of the SPL Token mint initialization instruction. + /// The uninitialized account to initialize as a mint. + /// The number of base-unit decimal places. + /// The authority allowed to mint tokens. + /// The authority allowed to freeze accounts, or null for none. + /// The token program to target; defaults to classic SPL Token. + /// The initializeMint2 instruction. + public static Instruction InitializeMint2( + PublicKey mint, + byte decimals, + PublicKey mintAuthority, + PublicKey? freezeAuthority = null, + PublicKey? tokenProgram = null) + => new() + { + ProgramId = tokenProgram ?? ProgramId, + Accounts = [AccountMeta.Writable(mint)], + Data = InitializeMintData(InitializeMint2Discriminator, decimals, mintAuthority, freezeAuthority) + }; + + /// Builds InitializeAccount2, which stores the owner in instruction data and still supplies Rent. + /// The uninitialized token account. + /// The mint the account will hold. + /// The account owner encoded in instruction data. + /// The token program to target; defaults to classic SPL Token. + /// The initializeAccount2 instruction. + public static Instruction InitializeAccount2( + PublicKey account, + PublicKey mint, + PublicKey owner, + PublicKey? tokenProgram = null) + => new() + { + ProgramId = tokenProgram ?? ProgramId, + Accounts = [AccountMeta.Writable(account), AccountMeta.Readonly(mint), AccountMeta.Readonly(RentSysvar)], + Data = PublicKeyData(InitializeAccount2Discriminator, owner) + }; + + /// Builds InitializeAccount3, with the owner in data and no Rent sysvar account. + /// The uninitialized token account. + /// The mint the account will hold. + /// The account owner encoded in instruction data. + /// The token program to target; defaults to classic SPL Token. + /// The initializeAccount3 instruction. + public static Instruction InitializeAccount3( + PublicKey account, + PublicKey mint, + PublicKey owner, + PublicKey? tokenProgram = null) + => new() + { + ProgramId = tokenProgram ?? ProgramId, + Accounts = [AccountMeta.Writable(account), AccountMeta.Readonly(mint)], + Data = PublicKeyData(InitializeAccount3Discriminator, owner) + }; + + /// Initializes an SPL Token multisig account and supplies the Rent sysvar. + /// The uninitialized writable multisig account. + /// The one to eleven possible signer accounts. + /// The number of member signatures required, from one through the signer count. + /// The token program to target; defaults to classic SPL Token. + /// The initializeMultisig instruction. + /// is null. + /// + /// The signer count is outside one through eleven, or is outside one + /// through the signer count. + /// + public static Instruction InitializeMultisig( + PublicKey multisig, + IReadOnlyList signerAccounts, + byte requiredSignatures, + PublicKey? tokenProgram = null) + => InitializeMultisigCore( + multisig, + signerAccounts, + requiredSignatures, + includeRent: true, + InitializeMultisigDiscriminator, + tokenProgram); + + /// Initializes an SPL Token multisig account without a Rent sysvar account. + /// The uninitialized writable multisig account. + /// The one to eleven possible signer accounts. + /// The number of member signatures required, from one through the signer count. + /// The token program to target; defaults to classic SPL Token. + /// The initializeMultisig2 instruction. + /// is null. + /// + /// The signer count is outside one through eleven, or is outside one + /// through the signer count. + /// + public static Instruction InitializeMultisig2( + PublicKey multisig, + IReadOnlyList signerAccounts, + byte requiredSignatures, + PublicKey? tokenProgram = null) + => InitializeMultisigCore( + multisig, + signerAccounts, + requiredSignatures, + includeRent: false, + InitializeMultisig2Discriminator, + tokenProgram); + + /// Builds SyncNative with the legacy explicit Rent sysvar account. + /// The wrapped-native token account to synchronize. + /// The token program to target; defaults to classic SPL Token. + /// The syncNative instruction with Rent appended. + public static Instruction SyncNativeWithRentSysvar(PublicKey account, PublicKey? tokenProgram = null) + => new() + { + ProgramId = tokenProgram ?? ProgramId, + Accounts = [AccountMeta.Writable(account), AccountMeta.Readonly(RentSysvar)], + Data = [SyncNativeDiscriminator] + }; + + /// Requests the required token-account size for a mint through program return data. + /// The mint whose account extensions determine the required size. + /// The token program to target; defaults to classic SPL Token. + /// The getAccountDataSize instruction. + public static Instruction GetAccountDataSize(PublicKey mint, PublicKey? tokenProgram = null) + => ReadonlyMintInstruction(mint, GetAccountDataSizeDiscriminator, tokenProgram); + + /// Initializes the immutable-owner extension before initializing a token account. + /// The uninitialized writable token account. + /// The token program to target; defaults to classic SPL Token. + /// The initializeImmutableOwner instruction. + public static Instruction InitializeImmutableOwner(PublicKey account, PublicKey? tokenProgram = null) + => new() + { + ProgramId = tokenProgram ?? ProgramId, + Accounts = [AccountMeta.Writable(account)], + Data = [InitializeImmutableOwnerDiscriminator] + }; + + /// Requests conversion of a raw token amount to its UI string through program return data. + /// The mint that defines the decimals or Token-2022 scaling rules. + /// The raw base-unit amount. + /// The token program to target; defaults to classic SPL Token. + /// The amountToUiAmount instruction. + public static Instruction AmountToUiAmount(PublicKey mint, ulong amount, PublicKey? tokenProgram = null) + => new() + { + ProgramId = tokenProgram ?? ProgramId, + Accounts = [AccountMeta.Readonly(mint)], + Data = AmountData(AmountToUiAmountDiscriminator, amount) + }; + + /// Requests conversion of a UI token amount string to raw units through program return data. + /// The mint that defines the decimals or Token-2022 scaling rules. + /// The UTF-8 UI amount string to parse. + /// The token program to target; defaults to classic SPL Token. + /// The uiAmountToAmount instruction. + /// is null. + /// contains invalid Unicode text. + public static Instruction UiAmountToAmount(PublicKey mint, string uiAmount, PublicKey? tokenProgram = null) + { + ArgumentNullException.ThrowIfNull(uiAmount); + + byte[] encoded; + try + { + encoded = StrictTokenUtf8.GetBytes(uiAmount); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException("A token UI amount must contain valid Unicode text.", nameof(uiAmount), exception); + } + + var data = new byte[encoded.Length + 1]; + data[0] = UiAmountToAmountDiscriminator; + encoded.CopyTo(data.AsSpan(1)); + return new Instruction + { + ProgramId = tokenProgram ?? ProgramId, + Accounts = [AccountMeta.Readonly(mint)], + Data = data + }; + } + + /// Withdraws lamports above rent exemption from a Token-program-owned account. + /// The writable source account. + /// The writable account receiving excess lamports. + /// The source account's authority; signs. + /// The token program to target; defaults to Token-2022, whose pinned runtime implements this newer interface instruction. + /// The withdrawExcessLamports instruction. + public static Instruction WithdrawExcessLamports( + PublicKey account, + PublicKey destination, + PublicKey authority, + PublicKey? tokenProgram = null) + => AuthorityLamportInstruction( + account, + destination, + authority, + [WithdrawExcessLamportsDiscriminator], + tokenProgram ?? Token2022Program.ProgramId); + + /// Withdraws excess lamports using a multisig authority. + /// The writable source account. + /// The writable account receiving excess lamports. + /// The multisig authority account. + /// The token program to target, or null for Token-2022. + /// The multisig member accounts that sign, in account order. + /// The withdrawExcessLamports instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction WithdrawExcessLamports( + PublicKey account, + PublicKey destination, + PublicKey authority, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority( + WithdrawExcessLamports(account, destination, authority, tokenProgram), + multisigSigners); + + /// Transfers some or all lamports out of a native wrapped-SOL token account. + /// The writable native token account. + /// The writable account receiving lamports. + /// The native account's authority; signs. + /// The number of lamports, or null to transfer the entire available balance. + /// The token program to target; defaults to Token-2022, whose pinned runtime implements this newer interface instruction. + /// The unwrapLamports instruction. + public static Instruction UnwrapLamports( + PublicKey account, + PublicKey destination, + PublicKey authority, + ulong? amount = null, + PublicKey? tokenProgram = null) + => AuthorityLamportInstruction( + account, + destination, + authority, + OptionalAmountData(amount), + tokenProgram ?? Token2022Program.ProgramId); + + /// Transfers some or all lamports out of a native account using a multisig authority. + /// The writable native token account. + /// The writable account receiving lamports. + /// The multisig authority account. + /// The number of lamports, or null to transfer the entire available balance. + /// The token program to target, or null for Token-2022. + /// The multisig member accounts that sign, in account order. + /// The unwrapLamports instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction UnwrapLamports( + PublicKey account, + PublicKey destination, + PublicKey authority, + ulong? amount, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority( + UnwrapLamports(account, destination, authority, amount, tokenProgram), + multisigSigners); + + /// Combines compatible Token instructions into the Token program's compact batch encoding. + /// The Token instructions to execute in order. + /// The token program to target; defaults to Token-2022, whose pinned runtime implements batching. + /// The batch instruction with concatenated account metas. + /// or an element is null. + /// + /// The list is empty, an instruction has no data, is itself a batch, targets another program, + /// or has more than 255 accounts or 255 data bytes. + /// + public static Instruction Batch(IReadOnlyList instructions, PublicKey? tokenProgram = null) + { + ArgumentNullException.ThrowIfNull(instructions); + if (instructions.Count == 0) + throw new ArgumentException("A Token batch must contain at least one instruction.", nameof(instructions)); + + var program = tokenProgram ?? Token2022Program.ProgramId; + var data = new List { BatchDiscriminator }; + var accounts = new List(); + + for (var i = 0; i < instructions.Count; i++) + { + var instruction = instructions[i] + ?? throw new ArgumentNullException(nameof(instructions), $"Instruction at index {i} is null."); + if (instruction.ProgramId != program) + throw new ArgumentException($"Instruction at index {i} targets a different program.", nameof(instructions)); + if (instruction.Data.Length == 0) + throw new ArgumentException($"Instruction at index {i} has no discriminator byte.", nameof(instructions)); + if (instruction.Data is [BatchDiscriminator, ..]) + throw new ArgumentException($"Instruction at index {i} is a nested batch.", nameof(instructions)); + if (instruction.Accounts.Count > byte.MaxValue) + throw new ArgumentException($"Instruction at index {i} has more than 255 accounts.", nameof(instructions)); + if (instruction.Data.Length > byte.MaxValue) + throw new ArgumentException($"Instruction at index {i} has more than 255 data bytes.", nameof(instructions)); + + data.Add((byte)instruction.Accounts.Count); + data.Add((byte)instruction.Data.Length); + data.AddRange(instruction.Data); + accounts.AddRange(instruction.Accounts); + } + + return new Instruction { ProgramId = program, Accounts = accounts, Data = [.. data] }; + } + + private static Instruction InitializeMultisigCore( + PublicKey multisig, + IReadOnlyList signerAccounts, + byte requiredSignatures, + bool includeRent, + byte discriminator, + PublicKey? tokenProgram) + { + ArgumentNullException.ThrowIfNull(signerAccounts); + if (signerAccounts.Count is < 1 or > MaxMultisigSigners) + throw new ArgumentOutOfRangeException( + nameof(signerAccounts), + signerAccounts.Count, + $"An SPL Token multisig requires between 1 and {MaxMultisigSigners} signer accounts."); + if (requiredSignatures is 0 || requiredSignatures > signerAccounts.Count) + throw new ArgumentOutOfRangeException( + nameof(requiredSignatures), + requiredSignatures, + "The required signature count must be between one and the number of signer accounts."); + + var accounts = new List(signerAccounts.Count + (includeRent ? 2 : 1)) + { + AccountMeta.Writable(multisig) + }; + if (includeRent) + accounts.Add(AccountMeta.Readonly(RentSysvar)); + for (var i = 0; i < signerAccounts.Count; i++) + accounts.Add(AccountMeta.Readonly(signerAccounts[i])); + + return new Instruction + { + ProgramId = tokenProgram ?? ProgramId, + Accounts = accounts, + Data = [discriminator, requiredSignatures] + }; + } + + private static Instruction ReadonlyMintInstruction(PublicKey mint, byte discriminator, PublicKey? tokenProgram) + => new() + { + ProgramId = tokenProgram ?? ProgramId, + Accounts = [AccountMeta.Readonly(mint)], + Data = [discriminator] + }; + + private static Instruction AuthorityLamportInstruction( + PublicKey account, + PublicKey destination, + PublicKey authority, + byte[] data, + PublicKey? tokenProgram) + => new() + { + ProgramId = tokenProgram ?? ProgramId, + Accounts = [AccountMeta.Writable(account), AccountMeta.Writable(destination), AccountMeta.ReadonlySigner(authority)], + Data = data + }; + + private static byte[] InitializeMintData( + byte discriminator, + byte decimals, + PublicKey mintAuthority, + PublicKey? freezeAuthority) + { + var data = new byte[freezeAuthority is null ? 35 : 67]; + data[0] = discriminator; + data[1] = decimals; + mintAuthority.CopyTo(data.AsSpan(2)); + if (freezeAuthority is { } freeze) + { + data[34] = 1; + freeze.CopyTo(data.AsSpan(35)); + } + + return data; + } + + private static byte[] PublicKeyData(byte discriminator, PublicKey publicKey) + { + var data = new byte[33]; + data[0] = discriminator; + publicKey.CopyTo(data.AsSpan(1)); + return data; + } + + private static byte[] OptionalAmountData(ulong? amount) + { + if (amount is null) + return [UnwrapLamportsDiscriminator, 0]; + + var data = new byte[10]; + data[0] = UnwrapLamportsDiscriminator; + data[1] = 1; + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(2), amount.Value); + return data; + } +} diff --git a/src/SolSharp.Programs/TokenProgram.cs b/src/SolSharp.Programs/TokenProgram.cs index aac7168..379a228 100644 --- a/src/SolSharp.Programs/TokenProgram.cs +++ b/src/SolSharp.Programs/TokenProgram.cs @@ -10,8 +10,10 @@ namespace SolSharp.Programs; /// tokenProgram so the same instructions can target Token-2022 (the layouts are shared); it defaults /// to the classic SPL Token program. /// -public static class TokenProgram +public static partial class TokenProgram { + private const int MaxMultisigSigners = 11; + /// The SPL Token program's address. public static readonly PublicKey ProgramId = PublicKey.Parse(SolanaProgramIds.TokenProgram); @@ -67,7 +69,8 @@ public static class TokenProgram /// /// Builds an (unchecked) token transfer of base units. Prefer - /// , which also verifies the mint and its decimals. + /// , + /// which also verifies the mint and its decimals. /// /// The source token account; debited. /// The destination token account; credited. @@ -94,6 +97,25 @@ public static Instruction Transfer(PublicKey source, PublicKey destination, Publ }; } + /// Builds an unchecked transfer authorized by an SPL Token multisig account. + /// The source token account; debited. + /// The destination token account; credited. + /// The multisig authority account. + /// The amount to transfer, in base units. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The transfer instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction Transfer( + PublicKey source, + PublicKey destination, + PublicKey authority, + ulong amount, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority(Transfer(source, destination, authority, amount, tokenProgram), multisigSigners); + /// Builds a checked token transfer, which also verifies the mint and its decimals - the recommended form. /// The source token account; debited. /// The token mint; verified by the program. @@ -131,6 +153,31 @@ public static Instruction TransferChecked( }; } + /// Builds a checked transfer authorized by an SPL Token multisig account. + /// The source token account; debited. + /// The token mint. + /// The destination token account; credited. + /// The multisig authority account. + /// The amount to transfer, in base units. + /// The mint's decimals. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The checked transfer instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction TransferChecked( + PublicKey source, + PublicKey mint, + PublicKey destination, + PublicKey authority, + ulong amount, + byte decimals, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority( + TransferChecked(source, mint, destination, authority, amount, decimals, tokenProgram), + multisigSigners); + /// Mints new base units to a token account. /// The mint to mint from (writable). /// The token account to credit (writable). @@ -146,6 +193,25 @@ public static Instruction MintTo(PublicKey mint, PublicKey destination, PublicKe Data = AmountData(MintToDiscriminator, amount) }; + /// Builds a mint-to instruction authorized by an SPL Token multisig account. + /// The mint to mint from. + /// The token account to credit. + /// The multisig mint-authority account. + /// The amount to mint, in base units. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The mintTo instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction MintTo( + PublicKey mint, + PublicKey destination, + PublicKey authority, + ulong amount, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority(MintTo(mint, destination, authority, amount, tokenProgram), multisigSigners); + /// Burns base units from a token account. /// The token account to debit (writable). /// The token mint (writable). @@ -161,6 +227,25 @@ public static Instruction Burn(PublicKey account, PublicKey mint, PublicKey auth Data = AmountData(BurnDiscriminator, amount) }; + /// Builds a burn instruction authorized by an SPL Token multisig account. + /// The token account to debit. + /// The token mint. + /// The multisig owner or delegate account. + /// The amount to burn, in base units. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The burn instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction Burn( + PublicKey account, + PublicKey mint, + PublicKey authority, + ulong amount, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority(Burn(account, mint, authority, amount, tokenProgram), multisigSigners); + /// Approves a delegate to transfer up to base units from a token account. /// The token account to delegate from (writable). /// The delegate authorized to transfer. @@ -176,6 +261,25 @@ public static Instruction Approve(PublicKey source, PublicKey @delegate, PublicK Data = AmountData(ApproveDiscriminator, amount) }; + /// Builds an approve instruction authorized by an SPL Token multisig owner. + /// The token account to delegate from. + /// The delegate to approve. + /// The multisig owner account. + /// The delegated amount, in base units. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The approve instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction Approve( + PublicKey source, + PublicKey @delegate, + PublicKey owner, + ulong amount, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority(Approve(source, @delegate, owner, amount, tokenProgram), multisigSigners); + /// Revokes a token account's current delegate. /// The token account whose delegate is revoked (writable). /// The account's owner; signs. @@ -189,6 +293,21 @@ public static Instruction Revoke(PublicKey source, PublicKey owner, PublicKey? t Data = [RevokeDiscriminator] }; + /// Builds a revoke instruction authorized by an SPL Token multisig owner. + /// The token account whose delegate is revoked. + /// The multisig owner account. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The revoke instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction Revoke( + PublicKey source, + PublicKey owner, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority(Revoke(source, owner, tokenProgram), multisigSigners); + /// /// Closes a token account and sends its rent lamports to . The token balance /// must be zero first (use this on an emptied or native account, e.g. to unwrap wSOL). @@ -206,6 +325,23 @@ public static Instruction CloseAccount(PublicKey account, PublicKey destination, Data = [CloseAccountDiscriminator] }; + /// Builds a close-account instruction authorized by an SPL Token multisig owner. + /// The token account to close. + /// The account receiving the reclaimed lamports. + /// The multisig owner account. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The closeAccount instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction CloseAccount( + PublicKey account, + PublicKey destination, + PublicKey owner, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority(CloseAccount(account, destination, owner, tokenProgram), multisigSigners); + /// Syncs a native (wrapped SOL) token account's token balance to its underlying lamports. /// The native token account to sync (writable). /// The token program to target; defaults to the classic SPL Token program. Pass SolanaProgramIds.Token2022Program for Token-2022. @@ -232,6 +368,23 @@ public static Instruction FreezeAccount(PublicKey account, PublicKey mint, Publi Data = [FreezeAccountDiscriminator] }; + /// Builds a freeze-account instruction authorized by an SPL Token multisig authority. + /// The token account to freeze. + /// The token mint. + /// The multisig freeze-authority account. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The freezeAccount instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction FreezeAccount( + PublicKey account, + PublicKey mint, + PublicKey authority, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority(FreezeAccount(account, mint, authority, tokenProgram), multisigSigners); + /// Thaws a frozen token account. /// The token account to thaw (writable). /// The token mint. @@ -246,6 +399,23 @@ public static Instruction ThawAccount(PublicKey account, PublicKey mint, PublicK Data = [ThawAccountDiscriminator] }; + /// Builds a thaw-account instruction authorized by an SPL Token multisig authority. + /// The token account to thaw. + /// The token mint. + /// The multisig freeze-authority account. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The thawAccount instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction ThawAccount( + PublicKey account, + PublicKey mint, + PublicKey authority, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority(ThawAccount(account, mint, authority, tokenProgram), multisigSigners); + /// Initializes a previously-created account as a token account for . /// The uninitialized account to initialize (writable). /// The mint the account will hold. @@ -309,6 +479,11 @@ public static Instruction InitializeMint(PublicKey mint, byte decimals, PublicKe /// The new authority, or null to remove the authority permanently. /// The token program to target; defaults to the classic SPL Token program. Pass SolanaProgramIds.Token2022Program for Token-2022. /// The setAuthority instruction. + /// + /// is a Token-2022 extension authority, but + /// targets the classic SPL Token program. + /// + /// is not a defined authority type. public static Instruction SetAuthority( PublicKey account, PublicKey currentAuthority, @@ -316,6 +491,15 @@ public static Instruction SetAuthority( PublicKey? newAuthority = null, PublicKey? tokenProgram = null) { + if ((byte)authorityType > (byte)AuthorityType.PermissionedBurn) + throw new ArgumentOutOfRangeException(nameof(authorityType), authorityType, "Unknown SPL Token authority type."); + + var program = tokenProgram ?? ProgramId; + if (program == ProgramId && (byte)authorityType > (byte)AuthorityType.CloseAccount) + throw new ArgumentException( + "Token-2022 extension authorities require the Token-2022 program.", + nameof(authorityType)); + // The new authority is a compact instruction COption (a 1-byte tag, plus the key only when present) - // the form the Rust spl-token builder packs. (solana-py pads None with 32 zero bytes; both unpack.) var data = new byte[newAuthority is null ? 3 : 35]; @@ -329,12 +513,38 @@ public static Instruction SetAuthority( return new Instruction { - ProgramId = tokenProgram ?? ProgramId, + ProgramId = program, Accounts = [AccountMeta.Writable(account), AccountMeta.ReadonlySigner(currentAuthority)], Data = data }; } + /// Builds a set-authority instruction authorized by an SPL Token multisig account. + /// The mint or token account whose authority changes. + /// The multisig authority being replaced. + /// Which authority to change. + /// The new authority, or null to remove it. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The setAuthority instruction. + /// is null. + /// + /// is empty or contains more than 11 accounts, or + /// is a Token-2022 extension authority while + /// targets classic SPL Token. + /// + /// is not a defined authority type. + public static Instruction SetAuthority( + PublicKey account, + PublicKey currentAuthority, + AuthorityType authorityType, + PublicKey? newAuthority, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority( + SetAuthority(account, currentAuthority, authorityType, newAuthority, tokenProgram), + multisigSigners); + /// Approves a delegate for up to base units, also verifying the mint and its decimals - the recommended form. /// The token account to delegate from (writable). /// The token mint; verified by the program. @@ -365,6 +575,31 @@ public static Instruction ApproveChecked( Data = CheckedData(ApproveCheckedDiscriminator, amount, decimals) }; + /// Builds a checked approve instruction authorized by an SPL Token multisig owner. + /// The token account to delegate from. + /// The token mint. + /// The delegate to approve. + /// The multisig owner account. + /// The delegated amount, in base units. + /// The mint's decimals. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The approveChecked instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction ApproveChecked( + PublicKey source, + PublicKey mint, + PublicKey @delegate, + PublicKey owner, + ulong amount, + byte decimals, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority( + ApproveChecked(source, mint, @delegate, owner, amount, decimals, tokenProgram), + multisigSigners); + /// Mints new base units to a token account, also verifying the mint's decimals - the recommended form. /// The mint to mint from (writable). /// The token account to credit (writable). @@ -387,6 +622,29 @@ public static Instruction MintToChecked( Data = CheckedData(MintToCheckedDiscriminator, amount, decimals) }; + /// Builds a checked mint-to instruction authorized by an SPL Token multisig account. + /// The mint to mint from. + /// The token account to credit. + /// The multisig mint-authority account. + /// The amount to mint, in base units. + /// The mint's decimals. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The mintToChecked instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction MintToChecked( + PublicKey mint, + PublicKey destination, + PublicKey authority, + ulong amount, + byte decimals, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority( + MintToChecked(mint, destination, authority, amount, decimals, tokenProgram), + multisigSigners); + /// Burns base units from a token account, also verifying the mint's decimals - the recommended form. /// The token account to debit (writable). /// The token mint (writable). @@ -409,6 +667,58 @@ public static Instruction BurnChecked( Data = CheckedData(BurnCheckedDiscriminator, amount, decimals) }; + /// Builds a checked burn instruction authorized by an SPL Token multisig account. + /// The token account to debit. + /// The token mint. + /// The multisig owner or delegate account. + /// The amount to burn, in base units. + /// The mint's decimals. + /// The token program to target, or null for classic SPL Token. + /// The multisig member accounts that sign the transaction, in account order. + /// The burnChecked instruction. + /// is null. + /// is empty or contains more than 11 accounts. + public static Instruction BurnChecked( + PublicKey account, + PublicKey mint, + PublicKey authority, + ulong amount, + byte decimals, + PublicKey? tokenProgram, + IReadOnlyList multisigSigners) + => WithMultisigAuthority( + BurnChecked(account, mint, authority, amount, decimals, tokenProgram), + multisigSigners); + + private static Instruction WithMultisigAuthority( + Instruction instruction, + IReadOnlyList multisigSigners) + { + ArgumentNullException.ThrowIfNull(multisigSigners); + if (multisigSigners.Count == 0) + throw new ArgumentException("A multisig instruction requires at least one member signer.", nameof(multisigSigners)); + if (multisigSigners.Count > MaxMultisigSigners) + throw new ArgumentException( + $"An SPL Token multisig supports at most {MaxMultisigSigners} member signer accounts, got {multisigSigners.Count}.", + nameof(multisigSigners)); + + var authorityIndex = instruction.Accounts.Count - 1; + var accounts = new AccountMeta[instruction.Accounts.Count + multisigSigners.Count]; + for (var i = 0; i < instruction.Accounts.Count; i++) + accounts[i] = instruction.Accounts[i]; + + accounts[authorityIndex] = AccountMeta.Readonly(instruction.Accounts[authorityIndex].PublicKey); + for (var i = 0; i < multisigSigners.Count; i++) + accounts[instruction.Accounts.Count + i] = AccountMeta.ReadonlySigner(multisigSigners[i]); + + return new Instruction + { + ProgramId = instruction.ProgramId, + Accounts = accounts, + Data = [.. instruction.Data] + }; + } + private static byte[] AmountData(byte discriminator, ulong amount) { var data = new byte[9]; diff --git a/src/SolSharp.Programs/Transaction.cs b/src/SolSharp.Programs/Transaction.cs index c1291ff..f39bf01 100644 --- a/src/SolSharp.Programs/Transaction.cs +++ b/src/SolSharp.Programs/Transaction.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using SolSharp.Core.Encoding; using SolSharp.Core.Primitives; using SolSharp.Wallet; @@ -5,8 +6,9 @@ namespace SolSharp.Programs; /// -/// A transaction: an (legacy or ) -/// plus one signature slot per required signer. Sign it with , then serialize with +/// A transaction: an plus one signature slot per required signer. +/// Legacy and v0 transactions encode signatures before the message; SIMD-0385 V1 transactions encode +/// the message first and signatures afterward. Sign it with , then serialize with /// or to submit it. /// public sealed class Transaction @@ -14,25 +16,68 @@ public sealed class Transaction /// The length of an Ed25519 signature in bytes (64). public const int SignatureLength = 64; - private readonly byte[][] _signatures; + private readonly Signature[] _signatures; + private byte[]? _signedMessageBytes; + private PublicKey[]? _signedRequiredSignerKeys; private Transaction(ITransactionMessage message) { Message = message; - _signatures = new byte[message.RequiredSignatures][]; - for (var i = 0; i < _signatures.Length; i++) - _signatures[i] = new byte[SignatureLength]; + _signatures = new Signature[message.RequiredSignatures]; + Signatures = Array.AsReadOnly(_signatures); } - private Transaction(ITransactionMessage message, byte[][] signatures) + private Transaction(ITransactionMessage message, Signature[] signatures, byte[] signedMessageBytes) { Message = message; _signatures = signatures; + Signatures = Array.AsReadOnly(_signatures); + _signedMessageBytes = signedMessageBytes; + _signedRequiredSignerKeys = CopyRequiredSignerKeys(message); } - /// The message being signed and sent. + /// + /// The message being signed and sent. After the transaction is successfully signed or deserialized, + /// serialization continues to use the captured message bytes even if this object graph is later mutated. + /// public ITransactionMessage Message { get; } + /// + /// The signature slots in required-signer order. An all-zero is an absent + /// signature in a partially signed transaction. The returned view is read-only and reflects later signing. + /// + public IReadOnlyList Signatures { get; } + + /// + /// The required signer keys in signature-slot order. A defensive copy is returned because compiled message + /// collections may be mutable; after the first signature, this uses the captured signer mapping. + /// + public IReadOnlyList RequiredSignerKeys + => [.. _signedRequiredSignerKeys ?? CopyRequiredSignerKeys(Message)]; + + /// true when every required signature slot is nonzero. + /// This checks presence. Use to verify the signatures cryptographically. + public bool IsFullySigned + { + get + { + foreach (var signature in _signatures) + if (signature == default) + return false; + + return true; + } + } + + /// The wire-format version selected by . + public TransactionVersion Version + => Message switch + { + MessageV1 => TransactionVersion.V1, + MessageV0 => TransactionVersion.V0, + _ => TransactionVersion.Legacy + }; + /// Creates an unsigned transaction for , with every signature slot zeroed. /// The compiled message. /// The unsigned transaction. @@ -43,11 +88,16 @@ public static Transaction Create(ITransactionMessage message) return new Transaction(message); } - /// Parses a transaction from its wire bytes: the signatures followed by a legacy or v0 message. + /// + /// Parses a transaction from its version-routed wire bytes, retaining the exact parsed message bytes + /// for stable reserialization. Legacy and v0 carry a compact signature count and signatures before the + /// message; V1 begins with and carries its fixed number of signatures + /// after the message. + /// /// The serialized transaction. /// The parsed transaction, carrying its signatures. /// - /// The data is truncated, a compact-u16 length in it is malformed, the message is invalid or breaks + /// The data is truncated, contains trailing bytes, has a malformed compact-u16 length, or the message is invalid or breaks /// one of Solana's sanitize rules, or the signature count does not match the message's required /// signatures. /// @@ -55,30 +105,15 @@ public static Transaction Deserialize(ReadOnlySpan data) { try { - var offset = 0; - var signatureCount = ShortVec.Decode(data[offset..], out var read); - offset += read; - - var signatures = new byte[signatureCount][]; - for (var i = 0; i < signatureCount; i++) - { - signatures[i] = data.Slice(offset, SignatureLength).ToArray(); - offset += SignatureLength; - } - - var messageBytes = data[offset..]; - ITransactionMessage message = messageBytes.Length > 0 && (messageBytes[0] & MessageV0.VersionPrefix) != 0 - ? MessageV0.Deserialize(messageBytes) - : global::SolSharp.Programs.Message.Deserialize(messageBytes); - - // Solana's sanitize step requires exactly one signature slot per required signer (a partially - // signed transaction carries zeroed slots, never fewer). Accepting a mismatch here would let - // Sign index past the slot array and surface as an unrelated IndexOutOfRangeException. - if (signatureCount != message.RequiredSignatures) - throw new FormatException( - $"The transaction carries {signatureCount} signature slot(s) but its message requires {message.RequiredSignatures}."); - - return new Transaction(message, signatures); + if (data.Length == 0) + throw new FormatException("The transaction data is empty."); + + if (data[0] == MessageV1.VersionPrefix) + return DeserializeV1(data); + if ((data[0] & MessageV0.VersionPrefix) != 0) + throw new FormatException($"Invalid transaction discriminator 0x{data[0]:X2}."); + + return DeserializeLegacyOrV0(data); } catch (Exception exception) when (exception is IndexOutOfRangeException or ArgumentOutOfRangeException) { @@ -89,30 +124,184 @@ public static Transaction Deserialize(ReadOnlySpan data) /// /// Signs the message with each signer, placing each signature in the slot matching the signer's position - /// among the required signers. + /// among the required signers. The first successful non-empty call captures the signed message bytes; + /// later mutations to do not change serialization or the bytes passed to another signer. /// /// The signers to apply; each must be a required signer of the message. /// This transaction, so calls can be chained. - /// is null. - /// A signer is not one of the message's required signers. - public Transaction Sign(params ISigner[] signers) + /// + /// or one of its elements is null. + /// + /// + /// A signer is not one of the message's required signers or returns a signature whose length is not + /// bytes. + /// + /// + /// This compatibility method permits a subset of required signers. Prefer when + /// that intent should be explicit, or when completion is required. + /// + public Transaction Sign(params ISigner[] signers) => PartialSign(signers); + + /// + /// Signs the message with any supplied subset of its required signers. Existing signature slots are + /// retained, enabling multi-stage, hardware-wallet, and air-gapped signing workflows. + /// + /// The subset of required signers to apply. + /// This transaction, so calls can be chained. + /// + /// or one of its elements is null. + /// + /// + /// A signer is not required by the message or returns something other than 64 bytes. + /// + public Transaction PartialSign(params ISigner[] signers) { ArgumentNullException.ThrowIfNull(signers); - var message = Message.Serialize(); - foreach (var signer in signers) + var requiredSignerKeys = _signedRequiredSignerKeys ?? CopyRequiredSignerKeys(Message); + var pending = new (int Index, Signature Signature)[signers.Length]; + for (var i = 0; i < signers.Length; i++) { - var index = RequiredSignerIndex(signer.PublicKey); + var signer = signers[i]; + ArgumentNullException.ThrowIfNull(signer, nameof(signers)); + + var index = RequiredSignerIndex(requiredSignerKeys, signer.PublicKey); if (index < 0) throw new ArgumentException($"{signer.PublicKey} is not a required signer of this transaction.", nameof(signers)); - _signatures[index] = signer.Sign(message); + pending[i].Index = index; } + var message = _signedMessageBytes ?? Message.Serialize(); + for (var i = 0; i < signers.Length; i++) + { + var signer = signers[i]; + var signature = signer.Sign(message); + if (signature is null || signature.Length != SignatureLength) + throw new ArgumentException( + $"A signer must return a {SignatureLength}-byte Ed25519 signature, got {signature?.Length ?? 0} bytes.", + nameof(signers)); + + pending[i].Signature = new Signature(signature); + } + + for (var i = 0; i < pending.Length; i++) + _signatures[pending[i].Index] = pending[i].Signature; + + if (signers.Length > 0) + { + _signedMessageBytes ??= message; + _signedRequiredSignerKeys ??= requiredSignerKeys; + } + + return this; + } + + /// + /// Applies the supplied signers and requires every signature slot to be populated when the call returns. + /// Use when only a subset of signers is currently available. + /// + /// Required signers to apply. + /// This fully populated transaction. + /// or an element is null. + /// A signer is not required or returns an invalid-length signature. + /// At least one required signature remains absent. + public Transaction SignAll(params ISigner[] signers) + { + PartialSign(signers); + if (!IsFullySigned) + throw new InvalidOperationException("The transaction is not fully signed; at least one required signature is absent."); + + return this; + } + + /// + /// Adds a signature produced outside this process to its required signer slot after verifying it against + /// the exact transaction message. + /// + /// The required signer key that produced the signature. + /// The externally produced signature. + /// This transaction, so calls can be chained. + /// is not a required signer. + /// The signature does not verify for the signer and message. + public Transaction AddSignature(PublicKey signer, Signature signature) + { + var requiredSignerKeys = _signedRequiredSignerKeys ?? CopyRequiredSignerKeys(Message); + var index = RequiredSignerIndex(requiredSignerKeys, signer); + if (index < 0) + throw new ArgumentException($"{signer} is not a required signer of this transaction.", nameof(signer)); + + var message = _signedMessageBytes ?? Message.Serialize(); + if (!signature.Verify(signer, message)) + throw new CryptographicException("The signature does not verify for this signer and transaction message."); + + _signatures[index] = signature; + _signedMessageBytes ??= message; + _signedRequiredSignerKeys ??= requiredSignerKeys; return this; } - /// Serializes the transaction to its wire bytes: the signatures followed by the message. + /// Returns the signature in 's required slot. + /// A required signer key. + /// The signature, or the all-zero value when that signer has not signed yet. + /// is not a required signer. + public Signature GetSignature(PublicKey signer) + { + var requiredSignerKeys = _signedRequiredSignerKeys ?? CopyRequiredSignerKeys(Message); + var index = RequiredSignerIndex(requiredSignerKeys, signer); + if (index < 0) + throw new ArgumentException($"{signer} is not a required signer of this transaction.", nameof(signer)); + + return _signatures[index]; + } + + /// Returns the exact message bytes that the current signature slots cover. + /// A defensive copy of the captured or currently serialized message. + public byte[] GetMessageBytes() => [.. _signedMessageBytes ?? Message.Serialize()]; + + /// Computes the domain-separated Solana hash of . + /// The 32-byte transaction message hash. + public Hash GetMessageHash() => TransactionMessageHash.Compute(_signedMessageBytes ?? Message.Serialize()); + + /// Verifies every required signature against the exact transaction message. + /// true only when every slot contains a valid signature. + public bool VerifySignatures() + { + foreach (var result in VerifySignaturesWithResults()) + if (!result) + return false; + + return true; + } + + /// Verifies each signature independently, in required-signer order. + /// One result per signature slot; absent all-zero signatures produce false. + public IReadOnlyList VerifySignaturesWithResults() + { + var message = _signedMessageBytes ?? Message.Serialize(); + var requiredSignerKeys = _signedRequiredSignerKeys ?? CopyRequiredSignerKeys(Message); + var results = new bool[_signatures.Length]; + for (var index = 0; index < results.Length; index++) + results[index] = _signatures[index].Verify(requiredSignerKeys[index], message); + + return results; + } + + /// Verifies every signature and returns the transaction message hash. + /// The domain-separated message hash. + /// At least one required signature is absent or invalid. + public Hash VerifyAndHashMessage() + { + if (!VerifySignatures()) + throw new CryptographicException("At least one transaction signature is absent or invalid."); + + return GetMessageHash(); + } + + /// + /// Serializes the transaction to its version-specific wire bytes. Legacy and v0 place signatures first; + /// V1 places the message first and its fixed number of signatures last. + /// /// The serialized transaction. /// The message's recent blockhash is not a 32-byte base58 value. public byte[] Serialize() @@ -125,9 +314,13 @@ public byte[] Serialize() /// Returns the exact length of the serialized transaction, in bytes. /// The serialized length. public int GetSerializedLength() - => ShortVec.GetByteCount(_signatures.Length) - + _signatures.Length * SignatureLength - + Message.GetSerializedLength(); + { + var messageLength = _signedMessageBytes?.Length ?? Message.GetSerializedLength(); + var signaturesLength = _signatures.Length * SignatureLength; + return Message is MessageV1 + ? messageLength + signaturesLength + : ShortVec.GetByteCount(_signatures.Length) + signaturesLength + messageLength; + } /// /// Serializes the transaction into without allocating - the hot-path @@ -145,14 +338,19 @@ public bool TrySerialize(Span destination, out int written) return false; } - var offset = ShortVec.Encode(_signatures.Length, destination); - foreach (var signature in _signatures) + var offset = 0; + if (Message is MessageV1) { - signature.CopyTo(destination[offset..]); - offset += SignatureLength; + offset += SerializeMessage(destination[offset..]); + offset += SerializeSignatures(destination[offset..]); + } + else + { + offset += ShortVec.Encode(_signatures.Length, destination); + offset += SerializeSignatures(destination[offset..]); + offset += SerializeMessage(destination[offset..]); } - offset += Message.Serialize(destination[offset..]); written = offset; return true; } @@ -162,10 +360,102 @@ public bool TrySerialize(Span destination, out int written) /// The message's recent blockhash is not a 32-byte base58 value. public string ToBase64() => Convert.ToBase64String(Serialize()); - private int RequiredSignerIndex(PublicKey key) + private static Transaction DeserializeLegacyOrV0(ReadOnlySpan data) + { + var offset = 0; + var signatureCount = ShortVec.Decode(data, out var read); + offset += read; + var signatureBytes = (long)signatureCount * SignatureLength; + if (signatureBytes >= data.Length - offset) + throw new FormatException( + $"The transaction declares {signatureCount} signature slot(s), but the remaining data cannot hold the signatures and a message."); + + var signatures = new Signature[signatureCount]; + for (var index = 0; index < signatureCount; index++) + { + signatures[index] = new Signature(data.Slice(offset, SignatureLength)); + offset += SignatureLength; + } + + var messageBytes = data[offset..]; + ITransactionMessage message; + if (messageBytes[0] == MessageV0.VersionPrefix) + { + message = MessageV0.Deserialize(messageBytes); + } + else if ((messageBytes[0] & MessageV0.VersionPrefix) != 0) + { + throw new FormatException($"Invalid message version byte 0x{messageBytes[0]:X2} in a legacy/v0 transaction envelope."); + } + else + { + message = global::SolSharp.Programs.Message.Deserialize(messageBytes); + } + + // Solana's sanitize step requires exactly one signature slot per required signer (a partially + // signed transaction carries zeroed slots, never fewer). Accepting a mismatch here would let + // Sign index past the slot array and surface as an unrelated IndexOutOfRangeException. + if (signatureCount != message.RequiredSignatures) + throw new FormatException( + $"The transaction carries {signatureCount} signature slot(s) but its message requires {message.RequiredSignatures}."); + + return new Transaction(message, signatures, messageBytes.ToArray()); + } + + private static Transaction DeserializeV1(ReadOnlySpan data) + { + var message = MessageV1.DeserializeTransactionMessage(data, out var messageLength); + var signatureBytes = message.RequiredSignatures * SignatureLength; + var remaining = data.Length - messageLength; + if (remaining != signatureBytes) + throw new FormatException( + $"The V1 transaction message requires {message.RequiredSignatures} signature slot(s) ({signatureBytes} bytes), but {remaining} byte(s) remain."); + + var signatures = new Signature[message.RequiredSignatures]; + var offset = messageLength; + for (var index = 0; index < signatures.Length; index++) + { + signatures[index] = new Signature(data.Slice(offset, SignatureLength)); + offset += SignatureLength; + } + + return new Transaction(message, signatures, data[..messageLength].ToArray()); + } + + private int SerializeMessage(Span destination) + { + if (_signedMessageBytes is not { } signedMessage) + return Message.Serialize(destination); + + signedMessage.CopyTo(destination); + return signedMessage.Length; + } + + private int SerializeSignatures(Span destination) + { + var offset = 0; + foreach (var signature in _signatures) + { + signature.CopyTo(destination[offset..]); + offset += SignatureLength; + } + + return offset; + } + + private static PublicKey[] CopyRequiredSignerKeys(ITransactionMessage message) + { + var keys = new PublicKey[message.RequiredSignatures]; + for (var i = 0; i < keys.Length; i++) + keys[i] = message.AccountKeys[i]; + + return keys; + } + + private static int RequiredSignerIndex(PublicKey[] requiredSignerKeys, PublicKey key) { - for (var i = 0; i < Message.RequiredSignatures; i++) - if (Message.AccountKeys[i] == key) + for (var i = 0; i < requiredSignerKeys.Length; i++) + if (requiredSignerKeys[i] == key) return i; return -1; diff --git a/src/SolSharp.Programs/TransactionBuilder.cs b/src/SolSharp.Programs/TransactionBuilder.cs index beb4e10..84bccbc 100644 --- a/src/SolSharp.Programs/TransactionBuilder.cs +++ b/src/SolSharp.Programs/TransactionBuilder.cs @@ -4,8 +4,9 @@ namespace SolSharp.Programs; /// -/// A fluent builder for legacy and v0 transactions: collect instructions, set the fee payer, recent -/// blockhash, and (for v0) address lookup tables, then compile and sign in one step. +/// A fluent builder for legacy, v0, and SIMD-0385 V1 transactions: collect instructions, set the fee +/// payer and lifetime specifier, optionally set lookup tables for v0 or inline configuration for V1, +/// then compile and sign in one step. /// public sealed class TransactionBuilder { @@ -14,6 +15,7 @@ public sealed class TransactionBuilder private PublicKey? _feePayer; private string? _recentBlockhash; private Instruction? _nonceAdvance; + private TransactionConfigV1 _v1Config = new(); /// Appends an instruction to the transaction. /// The instruction to add. @@ -29,10 +31,13 @@ public TransactionBuilder AddInstruction(Instruction instruction) /// Appends several instructions, in order. /// The instructions to add. /// This builder, so calls can be chained. - /// is null. + /// or one of its elements is null. public TransactionBuilder AddInstructions(params Instruction[] instructions) { ArgumentNullException.ThrowIfNull(instructions); + foreach (var instruction in instructions) + ArgumentNullException.ThrowIfNull(instruction, nameof(instructions)); + _instructions.AddRange(instructions); return this; } @@ -48,18 +53,29 @@ public TransactionBuilder SetFeePayer(PublicKey feePayer) /// /// Sets the recent blockhash (base58) the transaction is anchored to. Replaces any previously set - /// durable nonce (), dropping its prepended advance-nonce instruction - + /// durable nonce (SetDurableNonce), dropping its prepended advance-nonce instruction - /// the two anchoring modes are mutually exclusive. /// /// A recent blockhash, e.g. from getLatestBlockhash. /// This builder, so calls can be chained. + /// is null. public TransactionBuilder SetRecentBlockhash(string recentBlockhash) { + ArgumentNullException.ThrowIfNull(recentBlockhash); _recentBlockhash = recentBlockhash; _nonceAdvance = null; return this; } + /// + /// Sets the typed recent blockhash the transaction is anchored to. Replaces any previously set + /// durable nonce and drops its prepended advance-nonce instruction. + /// + /// A recent blockhash, e.g. from getLatestBlockhash. + /// This builder, so calls can be chained. + public TransactionBuilder SetRecentBlockhash(Hash recentBlockhash) + => SetRecentBlockhash(recentBlockhash.ToString()); + /// /// Anchors the transaction to a durable nonce instead of a recent blockhash: /// takes the blockhash slot, and an instruction is @@ -79,21 +95,52 @@ public TransactionBuilder SetDurableNonce(PublicKey nonceAccount, PublicKey auth return this; } + /// + /// Anchors the transaction to a typed durable nonce and prepends the required advance-nonce instruction. + /// Replaces any previously set recent blockhash or durable nonce. + /// + /// The durable nonce account. + /// The nonce authority; must sign the transaction. + /// The account's current nonce value. + /// This builder, so calls can be chained. + public TransactionBuilder SetDurableNonce(PublicKey nonceAccount, PublicKey authority, Hash nonce) + => SetDurableNonce(nonceAccount, authority, nonce.ToString()); + /// Sets the address lookup tables a v0 build () sources extra accounts from. /// The lookup tables; pass none to clear them. /// This builder, so calls can be chained. - /// is null. + /// or one of its elements is null. public TransactionBuilder SetAddressLookupTables(params AddressLookupTableAccount[] lookupTables) { ArgumentNullException.ThrowIfNull(lookupTables); + foreach (var lookupTable in lookupTables) + ArgumentNullException.ThrowIfNull(lookupTable, nameof(lookupTables)); + _lookupTables.Clear(); _lookupTables.AddRange(lookupTables); return this; } + /// + /// Sets the inline compute, loaded-account-data, heap, and total priority-fee configuration used by + /// and . Unspecified compute-unit and loaded-data + /// limits have the SIMD-0385 value zero; an unspecified heap uses . + /// + /// The V1 transaction configuration. + /// This builder, so calls can be chained. + /// is null. + public TransactionBuilder SetV1Config(TransactionConfigV1 config) + { + ArgumentNullException.ThrowIfNull(config); + _v1Config = config; + return this; + } + /// Compiles the collected instructions into an unsigned . /// The compiled message. - /// No fee payer, no recent blockhash, or no instructions were set. + /// + /// No fee payer or recent blockhash was set, or neither a user instruction nor a durable-nonce advance is present. + /// public Message BuildMessage() { var feePayer = _feePayer ?? throw new InvalidOperationException("A fee payer is required; call SetFeePayer."); @@ -103,12 +150,15 @@ public Message BuildMessage() /// Compiles the message and signs it with . /// The signers to apply. When no fee payer was set, the first signer becomes the fee payer. /// The signed transaction (unsigned if is empty). - /// is null. - /// No fee payer or signer, no recent blockhash, or no instructions were set. + /// or one of its elements is null. + /// + /// No fee payer or signer or recent blockhash was set, or neither a user instruction nor a durable-nonce advance is present. + /// /// A signer is not a required signer of the compiled message. public Transaction Build(params ISigner[] signers) { ArgumentNullException.ThrowIfNull(signers); + ValidateSigners(signers); var feePayer = _feePayer ?? (signers.Length > 0 ? signers[0].PublicKey @@ -122,19 +172,21 @@ private Message Compile(PublicKey feePayer) { if (_recentBlockhash is null) throw new InvalidOperationException("A recent blockhash is required; call SetRecentBlockhash."); - if (_instructions.Count == 0) + if (_instructions.Count == 0 && _nonceAdvance is null) throw new InvalidOperationException("At least one instruction is required."); return Message.Compile(feePayer, _recentBlockhash, EffectiveInstructions()); } // A durable-nonce transaction must run AdvanceNonceAccount as its first instruction. - private IReadOnlyList EffectiveInstructions() + private List EffectiveInstructions() => _nonceAdvance is null ? _instructions : [_nonceAdvance, .. _instructions]; /// Compiles the collected instructions into an unsigned v0 , using the set lookup tables. /// The compiled v0 message. - /// No fee payer, no recent blockhash, or no instructions were set. + /// + /// No fee payer or recent blockhash was set, or neither a user instruction nor a durable-nonce advance is present. + /// public MessageV0 BuildMessageV0() { var feePayer = _feePayer ?? throw new InvalidOperationException("A fee payer is required; call SetFeePayer."); @@ -144,12 +196,15 @@ public MessageV0 BuildMessageV0() /// Compiles a v0 message (using the set lookup tables) and signs it with . /// The signers to apply. When no fee payer was set, the first signer becomes the fee payer. /// The signed v0 transaction (unsigned if is empty). - /// is null. - /// No fee payer or signer, no recent blockhash, or no instructions were set. + /// or one of its elements is null. + /// + /// No fee payer or signer or recent blockhash was set, or neither a user instruction nor a durable-nonce advance is present. + /// /// A signer is not a required signer of the compiled message. public Transaction BuildV0(params ISigner[] signers) { ArgumentNullException.ThrowIfNull(signers); + ValidateSigners(signers); var feePayer = _feePayer ?? (signers.Length > 0 ? signers[0].PublicKey @@ -163,9 +218,70 @@ private MessageV0 CompileV0(PublicKey feePayer) { if (_recentBlockhash is null) throw new InvalidOperationException("A recent blockhash is required; call SetRecentBlockhash."); - if (_instructions.Count == 0) + if (_instructions.Count == 0 && _nonceAdvance is null) throw new InvalidOperationException("At least one instruction is required."); return MessageV0.Compile(feePayer, _recentBlockhash, EffectiveInstructions(), _lookupTables); } + + /// Compiles the collected instructions and inline configuration into an unsigned V1 message. + /// The compiled SIMD-0385 V1 message. + /// + /// No fee payer or lifetime specifier was set, no instruction is present, or address lookup tables were + /// supplied even though V1 stores all addresses inline. + /// + /// The message or configuration exceeds a V1 wire limit. + /// + /// If was not called, the message uses an empty configuration whose + /// compute-unit and loaded-account-data limits are zero and is normally unsuitable for submission. + /// + public MessageV1 BuildMessageV1() + { + var feePayer = _feePayer ?? throw new InvalidOperationException("A fee payer is required; call SetFeePayer."); + return CompileV1(feePayer); + } + + /// Compiles a V1 message and signs it with . + /// The signers to apply. When no fee payer was set, the first signer becomes the fee payer. + /// The signed V1 transaction (unsigned if is empty). + /// or one of its elements is null. + /// + /// No fee payer or signer or lifetime specifier was set, no instruction is present, or address lookup + /// tables were supplied even though V1 stores all addresses inline. + /// + /// A signer is not required, or the message/configuration exceeds a V1 limit. + /// + /// If was not called, the transaction uses an empty configuration whose + /// compute-unit and loaded-account-data limits are zero and is normally unsuitable for submission. + /// + public Transaction BuildV1(params ISigner[] signers) + { + ArgumentNullException.ThrowIfNull(signers); + ValidateSigners(signers); + + var feePayer = _feePayer ?? (signers.Length > 0 + ? signers[0].PublicKey + : throw new InvalidOperationException("A fee payer is required; call SetFeePayer or pass a signer.")); + + var transaction = Transaction.Create(CompileV1(feePayer)); + return signers.Length > 0 ? transaction.Sign(signers) : transaction; + } + + private MessageV1 CompileV1(PublicKey feePayer) + { + if (_recentBlockhash is null) + throw new InvalidOperationException("A lifetime specifier is required; call SetRecentBlockhash or SetDurableNonce."); + if (_instructions.Count == 0 && _nonceAdvance is null) + throw new InvalidOperationException("At least one instruction is required."); + if (_lookupTables.Count != 0) + throw new InvalidOperationException("V1 messages do not support address lookup tables; clear them before building V1."); + + return MessageV1.Compile(feePayer, _recentBlockhash, EffectiveInstructions(), _v1Config); + } + + private static void ValidateSigners(IReadOnlyList signers) + { + foreach (var signer in signers) + ArgumentNullException.ThrowIfNull(signer, nameof(signers)); + } } diff --git a/src/SolSharp.Programs/TransactionConfigV1.cs b/src/SolSharp.Programs/TransactionConfigV1.cs new file mode 100644 index 0000000..5e5d6ca --- /dev/null +++ b/src/SolSharp.Programs/TransactionConfigV1.cs @@ -0,0 +1,30 @@ +namespace SolSharp.Programs; + +/// +/// Inline compute and fee configuration carried by a SIMD-0385 V1 transaction message. A missing +/// compute-unit or loaded-account-data limit means zero; a missing heap size means 32 KiB. +/// +/// +/// An empty configuration is valid wire data, but its zero compute-unit and loaded-account-data limits +/// normally make it unsuitable for submission. Set the limits required by the transaction before sending it. +/// +public sealed record TransactionConfigV1 +{ + /// + /// The optional total priority fee in lamports. This is a total fee, not micro-lamports per + /// compute unit; a missing value means zero. + /// + public ulong? PriorityFee { get; init; } + + /// The optional maximum compute units; a missing value means zero. + public uint? ComputeUnitLimit { get; init; } + + /// The optional maximum loaded account-data bytes; a missing value means zero. + public uint? LoadedAccountsDataSizeLimit { get; init; } + + /// + /// The optional transaction heap size in bytes. A present value must be a multiple of 1024 from + /// 32 KiB through 256 KiB; a missing value means 32 KiB. + /// + public uint? HeapSize { get; init; } +} diff --git a/src/SolSharp.Programs/TransactionMessageHash.cs b/src/SolSharp.Programs/TransactionMessageHash.cs new file mode 100644 index 0000000..2a8be52 --- /dev/null +++ b/src/SolSharp.Programs/TransactionMessageHash.cs @@ -0,0 +1,41 @@ +using Org.BouncyCastle.Crypto.Digests; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// +/// Computes the domain-separated BLAKE3 hash used by the Solana SDK to identify serialized +/// transaction messages. The same algorithm applies to legacy and versioned message bytes. +/// +public static class TransactionMessageHash +{ + private const int DigestSizeBits = Hash.Length * 8; + + /// Computes the Solana message hash for . + /// The compiled transaction message. + /// The domain-separated 32-byte BLAKE3 hash. + /// is null. + /// The message cannot be serialized. + public static Hash Compute(ITransactionMessage message) + { + ArgumentNullException.ThrowIfNull(message); + return Compute(message.Serialize()); + } + + /// Computes the Solana message hash for exact serialized message bytes. + /// The serialized legacy or versioned message. + /// The domain-separated 32-byte BLAKE3 hash. + public static Hash Compute(ReadOnlySpan serializedMessage) + { + var digest = new Blake3Digest(DigestSizeBits); + digest.BlockUpdate("solana-tx-message-v1"u8); + digest.BlockUpdate(serializedMessage); + + Span result = stackalloc byte[Hash.Length]; + var written = digest.DoFinal(result); + if (written != Hash.Length) + throw new InvalidOperationException($"BLAKE3 produced {written} bytes instead of {Hash.Length}."); + + return new Hash(result); + } +} diff --git a/src/SolSharp.Programs/TransactionVersion.cs b/src/SolSharp.Programs/TransactionVersion.cs new file mode 100644 index 0000000..ef6b9bd --- /dev/null +++ b/src/SolSharp.Programs/TransactionVersion.cs @@ -0,0 +1,14 @@ +namespace SolSharp.Programs; + +/// The wire version of a compiled Solana transaction. +public enum TransactionVersion +{ + /// The original unversioned transaction message format. + Legacy = -1, + + /// The version 0 message format with address lookup tables. + V0 = 0, + + /// The SIMD-0385 version 1 format with inline transaction configuration. + V1 = 1 +} diff --git a/src/SolSharp.Programs/TransferHookProgram.cs b/src/SolSharp.Programs/TransferHookProgram.cs new file mode 100644 index 0000000..717ae1d --- /dev/null +++ b/src/SolSharp.Programs/TransferHookProgram.cs @@ -0,0 +1,473 @@ +using System.Buffers.Binary; +using System.Text; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Fetches account data for generic off-chain SPL extra-account resolution. +/// The account to fetch. +/// A token that cancels the fetch. +/// The account data, or null when the account does not exist. +public delegate ValueTask?> ExtraAccountDataResolver( + PublicKey address, + CancellationToken cancellationToken); + +/// Builders and off-chain account-resolution helpers for the SPL transfer-hook interface. +public static partial class TransferHookProgram +{ + private const int TlvHeaderLength = 8 + sizeof(uint); + private const int ListHeaderLength = sizeof(uint); + private const byte ExternalProgramBit = 0x80; + private static readonly byte[] ExecuteDiscriminator = [0x69, 0x25, 0x65, 0xc5, 0x4b, 0xfb, 0x66, 0x1a]; + private static readonly byte[] InitializeExtraAccountMetasDiscriminator = [0x2b, 0x22, 0x0d, 0x31, 0xa7, 0x58, 0xeb, 0xeb]; + private static readonly byte[] UpdateExtraAccountMetasDiscriminator = [0x9d, 0x69, 0x2a, 0x92, 0x66, 0x55, 0xf1, 0xae]; + private static readonly byte[] ExtraAccountMetasSeed = Encoding.ASCII.GetBytes("extra-account-metas"); + + /// Derives the validation-state PDA for a mint and transfer-hook program. + /// The Token-2022 mint. + /// The transfer-hook program. + /// The canonical validation-state PDA. + public static PublicKey GetExtraAccountMetasAddress(PublicKey mint, PublicKey hookProgramId) + => ProgramDerivedAddress.FindProgramAddress([ExtraAccountMetasSeed, mint.ToBytes()], hookProgramId).Address; + + /// Gets the exact account-data size for one execute extra-account TLV entry. + /// The number of 35-byte metadata entries. + /// The required number of bytes. + /// is negative or too large. + public static int GetExtraAccountMetaListSize(int numberOfEntries) + { + ArgumentOutOfRangeException.ThrowIfNegative(numberOfEntries); + try + { + return checked(TlvHeaderLength + ListHeaderLength + (numberOfEntries * ExtraAccountMeta.Length)); + } + catch (OverflowException) + { + throw new ArgumentOutOfRangeException(nameof(numberOfEntries), numberOfEntries, "The metadata list is too large."); + } + } + + /// Builds the transfer-hook execute instruction without validation or additional accounts. + /// The transfer-hook program. + /// The source token account. + /// The token mint. + /// The destination token account. + /// The transfer authority. + /// The transfer amount in base units. + /// The execute instruction. + public static Instruction Execute( + PublicKey hookProgramId, + PublicKey source, + PublicKey mint, + PublicKey destination, + PublicKey authority, + ulong amount) + { + var data = new byte[8 + sizeof(ulong)]; + ExecuteDiscriminator.CopyTo(data, 0); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(8), amount); + return new Instruction + { + ProgramId = hookProgramId, + Accounts = + [ + AccountMeta.Readonly(source), + AccountMeta.Readonly(mint), + AccountMeta.Readonly(destination), + AccountMeta.Readonly(authority) + ], + Data = data + }; + } + + /// Builds execute with a validation account and already-resolved extra account metas. + /// The transfer-hook program. + /// The source token account. + /// The token mint. + /// The destination token account. + /// The transfer authority. + /// The validation-state account. + /// The resolved additional accounts, in validation-list order. + /// The transfer amount in base units. + /// The execute instruction. + /// is null. + public static Instruction ExecuteWithExtraAccountMetas( + PublicKey hookProgramId, + PublicKey source, + PublicKey mint, + PublicKey destination, + PublicKey authority, + PublicKey validationAccount, + IReadOnlyList additionalAccounts, + ulong amount) + { + ArgumentNullException.ThrowIfNull(additionalAccounts); + var instruction = Execute(hookProgramId, source, mint, destination, authority, amount); + var accounts = new List(5 + additionalAccounts.Count); + accounts.AddRange(instruction.Accounts); + accounts.Add(AccountMeta.Readonly(validationAccount)); + accounts.AddRange(additionalAccounts); + return new Instruction { ProgramId = hookProgramId, Accounts = accounts, Data = instruction.Data }; + } + + /// Builds the transfer-hook instruction that initializes a validation account's metadata list. + /// The transfer-hook program. + /// The writable validation-state account. + /// The token mint. + /// The mint authority; signs. + /// The metadata entries to store. + /// The initialize-extra-account-metas instruction. + public static Instruction InitializeExtraAccountMetaList( + PublicKey hookProgramId, + PublicKey validationAccount, + PublicKey mint, + PublicKey mintAuthority, + IReadOnlyList extraAccountMetas) + => new() + { + ProgramId = hookProgramId, + Accounts = + [ + AccountMeta.Writable(validationAccount), + AccountMeta.Readonly(mint), + AccountMeta.ReadonlySigner(mintAuthority), + AccountMeta.Readonly(SystemProgram.ProgramId) + ], + Data = PackMetaListInstruction(InitializeExtraAccountMetasDiscriminator, extraAccountMetas) + }; + + /// Builds the transfer-hook instruction that replaces a validation account's metadata list. + /// The transfer-hook program. + /// The writable validation-state account. + /// The token mint. + /// The mint authority; signs. + /// The replacement metadata entries. + /// The update-extra-account-metas instruction. + public static Instruction UpdateExtraAccountMetaList( + PublicKey hookProgramId, + PublicKey validationAccount, + PublicKey mint, + PublicKey mintAuthority, + IReadOnlyList extraAccountMetas) + => new() + { + ProgramId = hookProgramId, + Accounts = + [ + AccountMeta.Writable(validationAccount), + AccountMeta.Readonly(mint), + AccountMeta.ReadonlySigner(mintAuthority) + ], + Data = PackMetaListInstruction(UpdateExtraAccountMetasDiscriminator, extraAccountMetas) + }; + + /// Encodes the execute metadata list as a complete generic SPL TLV account entry. + /// The metadata entries. + /// The validation-account bytes for one execute entry. + public static byte[] EncodeExecuteExtraAccountMetaList(IReadOnlyList extraAccountMetas) + { + var value = PackMetaListValue(extraAccountMetas); + var data = new byte[TlvHeaderLength + value.Length]; + ExecuteDiscriminator.CopyTo(data, 0); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(8), checked((uint)value.Length)); + value.CopyTo(data, TlvHeaderLength); + return data; + } + + /// Decodes the first execute metadata list from generic SPL TLV validation-account data. + /// The complete validation-account data. + /// The decoded entries, or null when the TLV data is malformed or has no execute entry. + public static IReadOnlyList? DecodeExecuteExtraAccountMetaList( + ReadOnlySpan validationAccountData) + { + var offset = 0; + while (offset < validationAccountData.Length) + { + var remaining = validationAccountData[offset..]; + if (remaining.Length < 8) + return null; + if (remaining[..8].IndexOfAnyExcept((byte)0) < 0) + return null; + if (remaining.Length < TlvHeaderLength) + return null; + + var valueLength = BinaryPrimitives.ReadUInt32LittleEndian(remaining[8..]); + if (valueLength > int.MaxValue || valueLength > remaining.Length - TlvHeaderLength) + return null; + var value = remaining.Slice(TlvHeaderLength, (int)valueLength); + if (remaining[..8].SequenceEqual(ExecuteDiscriminator)) + return DecodeMetaListValue(value); + offset += TlvHeaderLength + (int)valueLength; + } + + return null; + } + + /// Resolves the execute extra accounts from already-fetched validation-state data. + /// The transfer-hook program. + /// The source token account. + /// The token mint. + /// The destination token account. + /// The transfer authority. + /// The transfer amount. + /// The validation-state account data. + /// A client-agnostic account-data fetch function. + /// A token that cancels account fetches. + /// The resolved extra account metas in validation-list order. + /// is null. + /// The validation data or a metadata configuration is malformed. + /// A required account or account-data slice cannot be resolved. + public static async ValueTask> ResolveExecuteExtraAccountMetasAsync( + PublicKey hookProgramId, + PublicKey source, + PublicKey mint, + PublicKey destination, + PublicKey authority, + ulong amount, + ReadOnlyMemory validationAccountData, + ExtraAccountDataResolver accountDataResolver, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(accountDataResolver); + var configurations = DecodeExecuteExtraAccountMetaList(validationAccountData.Span) + ?? throw new FormatException("The validation account does not contain a valid transfer-hook execute metadata list."); + var validationAccount = GetExtraAccountMetasAddress(mint, hookProgramId); + var execute = Execute(hookProgramId, source, mint, destination, authority, amount); + var workingAccounts = new List(5 + configurations.Count); + workingAccounts.AddRange(execute.Accounts); + workingAccounts.Add(AccountMeta.Readonly(validationAccount)); + var accountData = new List?>(workingAccounts.Count + configurations.Count); + for (var i = 0; i < execute.Accounts.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + accountData.Add(await accountDataResolver(execute.Accounts[i].PublicKey, cancellationToken)); + } + + accountData.Add(validationAccountData); + + var resolved = new List(configurations.Count); + for (var i = 0; i < configurations.Count; i++) + { + var meta = Resolve(configurations[i], execute.Data, hookProgramId, workingAccounts, accountData); + meta = DeEscalate(meta, workingAccounts); + cancellationToken.ThrowIfCancellationRequested(); + var resolvedData = await accountDataResolver(meta.PublicKey, cancellationToken); + workingAccounts.Add(meta); + accountData.Add(resolvedData); + resolved.Add(meta); + } + + return resolved; + } + + /// + /// Fetches and resolves a mint's transfer-hook validation state, then appends the extra accounts plus the + /// hook program and validation account to a Token-2022 transfer instruction. + /// + /// The Token-2022 transfer instruction to augment. + /// The transfer-hook program. + /// The source token account. + /// The token mint. + /// The destination token account. + /// The transfer authority. + /// The transfer amount. + /// A client-agnostic account-data fetch function. + /// A token that cancels account fetches. + /// A new instruction with the required accounts appended. + /// or is null. + /// does not contain all four base execute accounts. + /// The validation account does not exist. + public static async ValueTask AddExtraAccountsForExecuteAsync( + Instruction tokenInstruction, + PublicKey hookProgramId, + PublicKey source, + PublicKey mint, + PublicKey destination, + PublicKey authority, + ulong amount, + ExtraAccountDataResolver accountDataResolver, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(tokenInstruction); + ArgumentNullException.ThrowIfNull(accountDataResolver); + foreach (var requiredKey in new[] { source, mint, destination, authority }) + { + if (!tokenInstruction.Accounts.Any(account => account.PublicKey == requiredKey)) + throw new ArgumentException("The token instruction is missing a required transfer-hook base account.", nameof(tokenInstruction)); + } + + var validationAccount = GetExtraAccountMetasAddress(mint, hookProgramId); + var validationData = await accountDataResolver(validationAccount, cancellationToken) + ?? throw new InvalidOperationException($"The transfer-hook validation account {validationAccount} does not exist."); + var extras = await ResolveExecuteExtraAccountMetasAsync( + hookProgramId, + source, + mint, + destination, + authority, + amount, + validationData, + accountDataResolver, + cancellationToken); + var accounts = new List(tokenInstruction.Accounts.Count + extras.Count + 2); + accounts.AddRange(tokenInstruction.Accounts); + accounts.AddRange(extras); + accounts.Add(AccountMeta.Readonly(hookProgramId)); + accounts.Add(AccountMeta.Readonly(validationAccount)); + return new Instruction { ProgramId = tokenInstruction.ProgramId, Accounts = accounts, Data = tokenInstruction.Data }; + } + + private static byte[] PackMetaListInstruction( + ReadOnlySpan discriminator, + IReadOnlyList extraAccountMetas) + { + var value = PackMetaListValue(extraAccountMetas); + var data = new byte[8 + value.Length]; + discriminator.CopyTo(data); + value.CopyTo(data, 8); + return data; + } + + private static byte[] PackMetaListValue(IReadOnlyList extraAccountMetas) + { + ArgumentNullException.ThrowIfNull(extraAccountMetas); + int length; + try + { + length = checked(ListHeaderLength + (extraAccountMetas.Count * ExtraAccountMeta.Length)); + } + catch (OverflowException exception) + { + throw new ArgumentException("The extra-account metadata list is too large.", nameof(extraAccountMetas), exception); + } + + var data = new byte[length]; + BinaryPrimitives.WriteUInt32LittleEndian(data, checked((uint)extraAccountMetas.Count)); + for (var i = 0; i < extraAccountMetas.Count; i++) + { + var meta = extraAccountMetas[i] + ?? throw new ArgumentNullException(nameof(extraAccountMetas), $"Metadata entry at index {i} is null."); + meta.Encode().CopyTo(data, ListHeaderLength + (i * ExtraAccountMeta.Length)); + } + + return data; + } + + private static ExtraAccountMeta[]? DecodeMetaListValue(ReadOnlySpan value) + { + if (value.Length < ListHeaderLength) + return null; + var count = BinaryPrimitives.ReadUInt32LittleEndian(value); + if (count > int.MaxValue) + return null; + var requiredLength = (long)ListHeaderLength + ((long)count * ExtraAccountMeta.Length); + if (requiredLength > value.Length) + return null; + var entries = new ExtraAccountMeta[(int)count]; + for (var i = 0; i < entries.Length; i++) + { + entries[i] = ExtraAccountMeta.Decode(value.Slice(ListHeaderLength + (i * ExtraAccountMeta.Length), ExtraAccountMeta.Length))!; + } + + return entries; + } + + private static AccountMeta Resolve( + ExtraAccountMeta configuration, + ReadOnlySpan instructionData, + PublicKey executingProgram, + IReadOnlyList accounts, + IReadOnlyList?> accountData) + { + PublicKey address; + if (configuration.Discriminator == 0) + { + address = new PublicKey(configuration.AddressConfigurationSpan); + } + else if (configuration.Discriminator is 1 or >= ExternalProgramBit) + { + var program = configuration.Discriminator == 1 + ? executingProgram + : AccountAt(accounts, configuration.Discriminator - ExternalProgramBit).PublicKey; + var seedConfigurations = configuration.DecodeSeeds() + ?? throw new FormatException("An extra-account PDA has an invalid seed configuration."); + var seeds = new byte[seedConfigurations.Count][]; + for (var i = 0; i < seedConfigurations.Count; i++) + seeds[i] = ResolveSeed(seedConfigurations[i], instructionData, accounts, accountData); + address = ProgramDerivedAddress.FindProgramAddress(seeds, program).Address; + } + else if (configuration.Discriminator == 2) + { + var data = configuration.AddressConfigurationSpan; + address = data[0] switch + { + 1 => ReadPublicKey(instructionData, data[1], "instruction data"), + 2 => ReadPublicKey(AccountDataAt(accountData, data[1]).Span, data[2], "account data"), + _ => throw new FormatException("An extra-account public-key data configuration is invalid.") + }; + } + else + { + throw new FormatException($"Unsupported extra-account metadata discriminator {configuration.Discriminator}."); + } + + return new AccountMeta(address, configuration.IsSigner, configuration.IsWritable); + } + + private static byte[] ResolveSeed( + ExtraAccountSeed seed, + ReadOnlySpan instructionData, + IReadOnlyList accounts, + IReadOnlyList?> accountData) + => seed.Kind switch + { + ExtraAccountSeedKind.Literal => seed.LiteralBytes.ToArray(), + ExtraAccountSeedKind.InstructionData => ReadSlice(instructionData, seed.Index, seed.Length, "instruction data"), + ExtraAccountSeedKind.AccountKey => AccountAt(accounts, seed.Index).PublicKey.ToBytes(), + ExtraAccountSeedKind.AccountData => ReadSlice( + AccountDataAt(accountData, seed.AccountIndex).Span, + seed.DataIndex, + seed.Length, + "account data"), + _ => throw new FormatException("Unknown extra-account seed kind.") + }; + + private static AccountMeta DeEscalate(AccountMeta requested, List existing) + { + var found = false; + var writable = false; + for (var i = 0; i < existing.Count; i++) + { + if (existing[i].PublicKey != requested.PublicKey) + continue; + found = true; + writable |= existing[i].IsWritable; + } + + var resolvedWritable = requested.IsWritable && (!found || writable); + return new AccountMeta(requested.PublicKey, isSigner: false, isWritable: resolvedWritable); + } + + private static AccountMeta AccountAt(IReadOnlyList accounts, int index) + => index < accounts.Count + ? accounts[index] + : throw new InvalidOperationException($"Extra-account resolution refers to missing account index {index}."); + + private static ReadOnlyMemory AccountDataAt(IReadOnlyList?> accountData, int index) + { + if (index >= accountData.Count) + throw new InvalidOperationException($"Extra-account resolution refers to missing account index {index}."); + return accountData[index] + ?? throw new InvalidOperationException($"Extra-account resolution requires data for account index {index}."); + } + + private static PublicKey ReadPublicKey(ReadOnlySpan data, int index, string source) + => new(ReadSlice(data, index, PublicKey.Length, source)); + + private static byte[] ReadSlice(ReadOnlySpan data, int index, int length, string source) + { + if (index > data.Length || length > data.Length - index) + throw new InvalidOperationException($"Extra-account resolution requests bytes outside the available {source}."); + return data.Slice(index, length).ToArray(); + } +} diff --git a/src/SolSharp.Programs/UpgradeableBpfLoaderProgram.cs b/src/SolSharp.Programs/UpgradeableBpfLoaderProgram.cs new file mode 100644 index 0000000..5e97ab7 --- /dev/null +++ b/src/SolSharp.Programs/UpgradeableBpfLoaderProgram.cs @@ -0,0 +1,330 @@ +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Builds instructions for Solana's upgradeable BPF Loader 3 interface. +public static class UpgradeableBpfLoaderProgram +{ + private const uint InitializeBufferDiscriminator = 0; + private const uint WriteDiscriminator = 1; + private const uint DeployDiscriminator = 2; + private const uint UpgradeDiscriminator = 3; + private const uint SetAuthorityDiscriminator = 4; + private const uint CloseDiscriminator = 5; + private const uint ExtendProgramDiscriminator = 6; + private const uint SetAuthorityCheckedDiscriminator = 7; + + private static readonly PublicKey ClockSysvar = PublicKey.Parse(Sysvars.Clock); + private static readonly PublicKey RentSysvar = PublicKey.Parse(Sysvars.Rent); + + /// The upgradeable BPF Loader 3 address. + public static readonly PublicKey ProgramId = + PublicKey.Parse("BPFLoaderUpgradeab1e11111111111111111111111"); + + /// The general minimum extension after activation of SIMD-0431. + public const uint MinimumExtendProgramBytes = 10_240; + + /// Derives the ProgramData PDA belonging to an executable Program account. + /// The executable Program account. + /// The canonical ProgramData PDA. + public static PublicKey GetProgramDataAddress(PublicKey programAccount) + => ProgramDerivedAddress.FindProgramAddress([programAccount.ToBytes()], ProgramId).Address; + + /// Creates and initializes a writable program-data buffer. + /// The funding signer. + /// The new buffer-account signer. + /// The initial buffer authority. + /// The lamports to fund. + /// The maximum bytes stored after buffer metadata. + /// The System create instruction followed by buffer initialization. + public static Instruction[] CreateBuffer( + PublicKey payer, + PublicKey buffer, + PublicKey authority, + ulong lamports, + ulong programLength) + { + var space = checked((ulong)UpgradeableBpfLoaderState.BufferMetadataLength + programLength); + return + [ + SystemProgram.CreateAccount(payer, buffer, lamports, space, ProgramId), + InitializeBuffer(buffer, authority) + ]; + } + + /// Initializes an already allocated buffer account. + /// The writable buffer account. + /// The optional, non-signing initial authority. + /// The initialize-buffer instruction. + public static Instruction InitializeBuffer(PublicKey buffer, PublicKey? authority) + { + var accounts = new List { AccountMeta.Writable(buffer) }; + if (authority is { } authorityAddress) + accounts.Add(AccountMeta.Readonly(authorityAddress)); + return CreateInstruction(ProgramWireEncoding.Build(InitializeBufferDiscriminator), accounts); + } + + /// Writes program bytes into a buffer. + /// The writable buffer account. + /// The buffer-authority signer. + /// The program-data byte offset. + /// The bytes to write. + /// The write instruction. + public static Instruction Write( + PublicKey buffer, + PublicKey authority, + uint offset, + ReadOnlySpan bytes) + { + var payload = bytes.ToArray(); + return CreateInstruction( + ProgramWireEncoding.Build(WriteDiscriminator, stream => + { + ProgramWireEncoding.WriteUInt32(stream, offset); + ProgramWireEncoding.WriteByteVector(stream, payload); + }), + [AccountMeta.Writable(buffer), AccountMeta.ReadonlySigner(authority)]); + } + + /// Creates a Program account and deploys the contents of a buffer. + /// The writable funding signer for ProgramData creation. + /// The new executable Program-account signer. + /// The writable source buffer. + /// The upgrade-authority signer. + /// The lamports used to create the Program account. + /// The maximum program-data length. + /// Whether deployment should close the source buffer. + /// The System create instruction followed by the deploy instruction. + public static Instruction[] DeployWithMaximumProgramLength( + PublicKey payer, + PublicKey programAccount, + PublicKey buffer, + PublicKey upgradeAuthority, + ulong programLamports, + ulong maximumProgramLength, + bool closeBuffer = true) + => + [ + SystemProgram.CreateAccount( + payer, + programAccount, + programLamports, + UpgradeableBpfLoaderState.ProgramMetadataLength, + ProgramId), + DeployInstruction( + payer, + programAccount, + buffer, + upgradeAuthority, + maximumProgramLength, + closeBuffer) + ]; + + /// Builds the deploy instruction for an already allocated Program account. + /// The writable funding signer for ProgramData creation. + /// The writable Program account. + /// The writable source buffer. + /// The upgrade-authority signer. + /// The maximum program-data length. + /// Whether deployment should close the source buffer. + /// The deploy instruction. + public static Instruction DeployInstruction( + PublicKey payer, + PublicKey programAccount, + PublicKey buffer, + PublicKey upgradeAuthority, + ulong maximumProgramLength, + bool closeBuffer = true) + => CreateInstruction( + ProgramWireEncoding.Build(DeployDiscriminator, stream => + { + ProgramWireEncoding.WriteUInt64(stream, maximumProgramLength); + ProgramWireEncoding.WriteByte(stream, closeBuffer ? (byte)1 : (byte)0); + }), + [ + AccountMeta.WritableSigner(payer), + AccountMeta.Writable(GetProgramDataAddress(programAccount)), + AccountMeta.Writable(programAccount), + AccountMeta.Writable(buffer), + AccountMeta.Readonly(RentSysvar), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.Readonly(SystemProgram.ProgramId), + AccountMeta.ReadonlySigner(upgradeAuthority) + ]); + + /// Upgrades a program from a populated buffer. + /// The writable executable Program account. + /// The writable source buffer. + /// The upgrade-authority signer. + /// The writable recipient of excess lamports. + /// Whether the upgrade should close the source buffer. + /// The upgrade instruction. + public static Instruction Upgrade( + PublicKey programAccount, + PublicKey buffer, + PublicKey authority, + PublicKey spill, + bool closeBuffer = true) + => CreateInstruction( + ProgramWireEncoding.Build( + UpgradeDiscriminator, + stream => ProgramWireEncoding.WriteByte(stream, closeBuffer ? (byte)1 : (byte)0)), + [ + AccountMeta.Writable(GetProgramDataAddress(programAccount)), + AccountMeta.Writable(programAccount), + AccountMeta.Writable(buffer), + AccountMeta.Writable(spill), + AccountMeta.Readonly(RentSysvar), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.ReadonlySigner(authority) + ]); + + /// Changes a buffer authority without requiring the replacement key to sign. + /// The writable buffer. + /// The current authority signer. + /// The replacement authority. + /// The set-authority instruction. + public static Instruction SetBufferAuthority( + PublicKey buffer, + PublicKey currentAuthority, + PublicKey newAuthority) + => SetAuthority(buffer, currentAuthority, newAuthority, isChecked: false); + + /// Changes a buffer authority and requires the replacement key to sign. + /// The writable buffer. + /// The current authority signer. + /// The replacement authority signer. + /// The checked set-authority instruction. + public static Instruction SetBufferAuthorityChecked( + PublicKey buffer, + PublicKey currentAuthority, + PublicKey newAuthority) + => SetAuthority(buffer, currentAuthority, newAuthority, isChecked: true); + + /// Changes or permanently revokes a program's upgrade authority. + /// The executable Program account used to derive ProgramData. + /// The current authority signer. + /// The replacement authority, or null to make the program immutable. + /// The set-authority instruction. + public static Instruction SetUpgradeAuthority( + PublicKey programAccount, + PublicKey currentAuthority, + PublicKey? newAuthority) + => SetAuthority(GetProgramDataAddress(programAccount), currentAuthority, newAuthority, isChecked: false); + + /// Changes a program's upgrade authority and requires the replacement key to sign. + /// The executable Program account used to derive ProgramData. + /// The current authority signer. + /// The replacement authority signer. + /// The checked set-authority instruction. + public static Instruction SetUpgradeAuthorityChecked( + PublicKey programAccount, + PublicKey currentAuthority, + PublicKey newAuthority) + => SetAuthority( + GetProgramDataAddress(programAccount), + currentAuthority, + newAuthority, + isChecked: true); + + /// Closes a buffer and transfers its lamports to a recipient. + /// The writable buffer to close. + /// The writable lamport recipient. + /// The buffer-authority signer. + /// The close instruction. + public static Instruction CloseBuffer( + PublicKey buffer, + PublicKey recipient, + PublicKey authority) + => Close(buffer, recipient, authority, null, tombstone: false); + + /// Closes an upgradeable-loader account or tombstones an executable program. + /// The writable account to close, or ProgramData when closing a program. + /// The writable lamport recipient. + /// The optional authority signer. + /// The optional writable Program account paired with ProgramData. + /// Whether to tombstone the associated Program address. + /// The close instruction. + public static Instruction Close( + PublicKey account, + PublicKey recipient, + PublicKey? authority, + PublicKey? associatedProgram, + bool tombstone = false) + { + var accounts = new List + { + AccountMeta.Writable(account), + AccountMeta.Writable(recipient) + }; + if (authority is { } authorityAddress) + accounts.Add(AccountMeta.ReadonlySigner(authorityAddress)); + if (associatedProgram is { } programAddress) + accounts.Add(AccountMeta.Writable(programAddress)); + + return CreateInstruction( + ProgramWireEncoding.Build( + CloseDiscriminator, + stream => ProgramWireEncoding.WriteByte(stream, tombstone ? (byte)1 : (byte)0)), + accounts); + } + + /// Extends a ProgramData account. + /// The writable Program account used to derive ProgramData. + /// The number of bytes to add. + /// An optional writable funding signer. + /// The extend-program instruction. + public static Instruction ExtendProgram( + PublicKey programAccount, + uint additionalBytes, + PublicKey? payer = null) + { + var accounts = new List + { + AccountMeta.Writable(GetProgramDataAddress(programAccount)), + AccountMeta.Writable(programAccount) + }; + if (payer is { } payerAddress) + { + accounts.Add(AccountMeta.Readonly(SystemProgram.ProgramId)); + accounts.Add(AccountMeta.WritableSigner(payerAddress)); + } + + return CreateInstruction( + ProgramWireEncoding.Build( + ExtendProgramDiscriminator, + stream => ProgramWireEncoding.WriteUInt32(stream, additionalBytes)), + accounts); + } + + private static Instruction SetAuthority( + PublicKey account, + PublicKey currentAuthority, + PublicKey? newAuthority, + bool isChecked) + { + if (isChecked && newAuthority is null) + throw new ArgumentNullException(nameof(newAuthority)); + + var accounts = new List + { + AccountMeta.Writable(account), + AccountMeta.ReadonlySigner(currentAuthority) + }; + if (newAuthority is { } authorityAddress) + { + accounts.Add(isChecked + ? AccountMeta.ReadonlySigner(authorityAddress) + : AccountMeta.Readonly(authorityAddress)); + } + + return CreateInstruction( + ProgramWireEncoding.Build( + isChecked ? SetAuthorityCheckedDiscriminator : SetAuthorityDiscriminator), + accounts); + } + + private static Instruction CreateInstruction(byte[] data, IReadOnlyList accounts) + => new() { ProgramId = ProgramId, Accounts = accounts, Data = data }; +} diff --git a/src/SolSharp.Programs/UpgradeableBpfLoaderState.cs b/src/SolSharp.Programs/UpgradeableBpfLoaderState.cs new file mode 100644 index 0000000..5974335 --- /dev/null +++ b/src/SolSharp.Programs/UpgradeableBpfLoaderState.cs @@ -0,0 +1,163 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// The account variant used by the upgradeable BPF loader. +public enum UpgradeableBpfLoaderStateKind : uint +{ + /// The account is uninitialized. + Uninitialized = 0, + + /// The account is a program-data staging buffer. + Buffer = 1, + + /// The account is the executable program facade. + Program = 2, + + /// The account stores deployed program bytes and upgrade metadata. + ProgramData = 3 +} + +/// Decoded metadata and program bytes from an upgradeable-loader account. +public sealed class UpgradeableBpfLoaderState +{ + private readonly byte[] _programBytes; + + private UpgradeableBpfLoaderState( + UpgradeableBpfLoaderStateKind kind, + PublicKey? authority, + PublicKey? programDataAddress, + ulong? slot, + ReadOnlySpan programBytes) + { + Kind = kind; + Authority = authority; + ProgramDataAddress = programDataAddress; + Slot = slot; + _programBytes = programBytes.ToArray(); + } + + /// The uninitialized-state metadata size. + public const int UninitializedMetadataLength = 4; + + /// The buffer metadata size, including reserved optional-authority space. + public const int BufferMetadataLength = 37; + + /// The executable Program metadata size. + public const int ProgramMetadataLength = 36; + + /// The ProgramData metadata size, including reserved optional-authority space. + public const int ProgramDataMetadataLength = 45; + + /// The decoded account variant. + public UpgradeableBpfLoaderStateKind Kind { get; } + + /// The buffer or upgrade authority, or null for immutable accounts. + public PublicKey? Authority { get; } + + /// The ProgramData address referenced by a Program account. + public PublicKey? ProgramDataAddress { get; } + + /// The last modification slot for ProgramData. + public ulong? Slot { get; } + + /// The bytes following Buffer or ProgramData metadata. + public ReadOnlyMemory ProgramBytes => _programBytes; + + /// Decodes upgradeable-loader account data. + /// The complete account data. + /// The decoded state and any trailing program bytes. + /// The input is too short for its state variant. + /// The discriminator or optional-authority tag is invalid. + public static UpgradeableBpfLoaderState Parse(ReadOnlySpan data) + { + if (data.Length < sizeof(uint)) + throw new ArgumentException("Upgradeable-loader state requires a four-byte discriminator.", nameof(data)); + + var kindValue = BinaryPrimitives.ReadUInt32LittleEndian(data); + return kindValue switch + { + 0 => new UpgradeableBpfLoaderState( + UpgradeableBpfLoaderStateKind.Uninitialized, + null, + null, + null, + []), + 1 => ParseBuffer(data), + 2 => ParseProgram(data), + 3 => ParseProgramData(data), + _ => throw new FormatException($"Unknown upgradeable-loader state discriminator {kindValue}.") + }; + } + + /// Attempts to decode upgradeable-loader account data. + /// The complete account data. + /// The decoded state on success; otherwise null. + /// true when the account data is valid. + public static bool TryParse(ReadOnlySpan data, out UpgradeableBpfLoaderState? state) + { + try + { + state = Parse(data); + return true; + } + catch (ArgumentException) + { + state = null; + return false; + } + catch (FormatException) + { + state = null; + return false; + } + } + + private static UpgradeableBpfLoaderState ParseBuffer(ReadOnlySpan data) + { + EnsureLength(data, BufferMetadataLength); + return new UpgradeableBpfLoaderState( + UpgradeableBpfLoaderStateKind.Buffer, + ReadOptionalPublicKey(data[4..], "buffer authority"), + null, + null, + data[BufferMetadataLength..]); + } + + private static UpgradeableBpfLoaderState ParseProgram(ReadOnlySpan data) + { + EnsureLength(data, ProgramMetadataLength); + return new UpgradeableBpfLoaderState( + UpgradeableBpfLoaderStateKind.Program, + null, + new PublicKey(data.Slice(4, PublicKey.Length)), + null, + []); + } + + private static UpgradeableBpfLoaderState ParseProgramData(ReadOnlySpan data) + { + EnsureLength(data, ProgramDataMetadataLength); + return new UpgradeableBpfLoaderState( + UpgradeableBpfLoaderStateKind.ProgramData, + ReadOptionalPublicKey(data[12..], "upgrade authority"), + null, + BinaryPrimitives.ReadUInt64LittleEndian(data[4..]), + data[ProgramDataMetadataLength..]); + } + + private static PublicKey? ReadOptionalPublicKey(ReadOnlySpan data, string description) + => data[0] switch + { + 0 => null, + 1 => new PublicKey(data.Slice(1, PublicKey.Length)), + var tag => throw new FormatException($"Invalid {description} option tag {tag}.") + }; + + private static void EnsureLength(ReadOnlySpan data, int required) + { + if (data.Length < required) + throw new ArgumentException($"State requires at least {required} bytes, got {data.Length}.", nameof(data)); + } +} diff --git a/src/SolSharp.Programs/VoteProgram.cs b/src/SolSharp.Programs/VoteProgram.cs new file mode 100644 index 0000000..f8ec40a --- /dev/null +++ b/src/SolSharp.Programs/VoteProgram.cs @@ -0,0 +1,693 @@ +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// Builds bincode-compatible instructions for Solana's native Vote program. +public static class VoteProgram +{ + private const uint InitializeAccountDiscriminator = 0; + private const uint AuthorizeDiscriminator = 1; + private const uint VoteDiscriminator = 2; + private const uint WithdrawDiscriminator = 3; + private const uint UpdateValidatorIdentityDiscriminator = 4; + private const uint UpdateCommissionDiscriminator = 5; + private const uint VoteSwitchDiscriminator = 6; + private const uint AuthorizeCheckedDiscriminator = 7; + private const uint UpdateVoteStateDiscriminator = 8; + private const uint UpdateVoteStateSwitchDiscriminator = 9; + private const uint AuthorizeWithSeedDiscriminator = 10; + private const uint AuthorizeCheckedWithSeedDiscriminator = 11; + private const uint CompactUpdateVoteStateDiscriminator = 12; + private const uint CompactUpdateVoteStateSwitchDiscriminator = 13; + private const uint TowerSyncDiscriminator = 14; + private const uint TowerSyncSwitchDiscriminator = 15; + private const uint InitializeAccountV2Discriminator = 16; + private const uint UpdateCommissionCollectorDiscriminator = 17; + private const uint UpdateCommissionBpsDiscriminator = 18; + private const uint DepositDelegatorRewardsDiscriminator = 19; + + private static readonly PublicKey ClockSysvar = PublicKey.Parse(Sysvars.Clock); + private static readonly PublicKey RentSysvar = PublicKey.Parse(Sysvars.Rent); + private static readonly PublicKey SlotHashesSysvar = + PublicKey.Parse("SysvarS1otHashes111111111111111111111111111"); + + /// The native Vote program address. + public static readonly PublicKey ProgramId = + PublicKey.Parse("Vote111111111111111111111111111111111111111"); + + /// The account size used by the current V4 vote state. + public const int AccountDataLength = 3762; + + /// Initializes an allocated legacy vote account. + /// The uninitialized writable vote account. + /// The initialization values. + /// The initialize instruction. + public static Instruction InitializeAccount(PublicKey voteAccount, VoteInitialize initialize) + { + var data = ProgramWireEncoding.Build(InitializeAccountDiscriminator, stream => + { + ProgramWireEncoding.WritePublicKey(stream, initialize.Node); + ProgramWireEncoding.WritePublicKey(stream, initialize.AuthorizedVoter); + ProgramWireEncoding.WritePublicKey(stream, initialize.AuthorizedWithdrawer); + ProgramWireEncoding.WriteByte(stream, initialize.Commission); + }); + return CreateInstruction( + data, + [ + AccountMeta.Writable(voteAccount), + AccountMeta.Readonly(RentSysvar), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.ReadonlySigner(initialize.Node) + ]); + } + + /// Initializes an allocated V4 vote account with BLS credentials and collector accounts. + /// The uninitialized writable vote account. + /// The V4 initialization values. + /// The writable inflation-rewards collector. + /// The writable block-revenue collector. + /// The V2 initialize instruction. + public static Instruction InitializeAccountV2( + PublicKey voteAccount, + VoteInitializeV2 initialize, + PublicKey inflationRewardsCollector, + PublicKey blockRevenueCollector) + { + ArgumentNullException.ThrowIfNull(initialize); + initialize.ValidateProofForVoteAccount(voteAccount); + var data = ProgramWireEncoding.Build(InitializeAccountV2Discriminator, stream => + { + ProgramWireEncoding.WritePublicKey(stream, initialize.Node); + ProgramWireEncoding.WritePublicKey(stream, initialize.AuthorizedVoter); + stream.Write(initialize.BlsPublicKeySpan); + stream.Write(initialize.BlsProofOfPossessionSpan); + ProgramWireEncoding.WritePublicKey(stream, initialize.AuthorizedWithdrawer); + ProgramWireEncoding.WriteUInt16(stream, initialize.InflationRewardsCommissionBps); + ProgramWireEncoding.WriteUInt16(stream, initialize.BlockRevenueCommissionBps); + }); + return CreateInstruction( + data, + [ + AccountMeta.Writable(voteAccount), + AccountMeta.ReadonlySigner(initialize.Node), + AccountMeta.Writable(inflationRewardsCollector), + AccountMeta.Writable(blockRevenueCollector) + ]); + } + + /// Creates and initializes a legacy vote account. + /// The funding signer. + /// The new vote-account signer. + /// The initialization values. + /// The lamports to fund. + /// The allocated size; defaults to current V4 capacity. + /// The System create instruction followed by Vote initialize. + public static Instruction[] CreateAccount( + PublicKey payer, + PublicKey voteAccount, + VoteInitialize initialize, + ulong lamports, + ulong space = AccountDataLength) + => + [ + SystemProgram.CreateAccount(payer, voteAccount, lamports, space, ProgramId), + InitializeAccount(voteAccount, initialize) + ]; + + /// Creates and initializes a legacy vote account derived with a System-program seed. + /// The funding signer. + /// The derived vote-account address. + /// The derivation base signer. + /// The System-program seed. + /// The initialization values. + /// The lamports to fund. + /// The allocated size; defaults to current V4 capacity. + /// The create-with-seed instruction followed by Vote initialize. + public static Instruction[] CreateAccountWithSeed( + PublicKey payer, + PublicKey voteAccount, + PublicKey baseAccount, + string seed, + VoteInitialize initialize, + ulong lamports, + ulong space = AccountDataLength) + => + [ + SystemProgram.CreateAccountWithSeed( + payer, + voteAccount, + baseAccount, + seed, + lamports, + space, + ProgramId), + InitializeAccount(voteAccount, initialize) + ]; + + /// Creates and V2-initializes a current V4 vote account. + /// The funding signer. + /// The new vote-account signer. + /// The V4 initialization values. + /// The writable inflation-rewards collector. + /// The writable block-revenue collector. + /// The lamports to fund. + /// The allocated size; defaults to current V4 capacity. + /// The System create instruction followed by V2 initialization. + public static Instruction[] CreateAccountV2( + PublicKey payer, + PublicKey voteAccount, + VoteInitializeV2 initialize, + PublicKey inflationRewardsCollector, + PublicKey blockRevenueCollector, + ulong lamports, + ulong space = AccountDataLength) + => + [ + SystemProgram.CreateAccount(payer, voteAccount, lamports, space, ProgramId), + InitializeAccountV2(voteAccount, initialize, inflationRewardsCollector, blockRevenueCollector) + ]; + + /// Creates and V2-initializes a derived current vote account. + /// The funding signer. + /// The derived vote-account address. + /// The derivation base signer. + /// The System-program seed. + /// The V4 initialization values. + /// The writable inflation-rewards collector. + /// The writable block-revenue collector. + /// The lamports to fund. + /// The allocated size; defaults to current V4 capacity. + /// The create-with-seed instruction followed by V2 initialization. + public static Instruction[] CreateAccountV2WithSeed( + PublicKey payer, + PublicKey voteAccount, + PublicKey baseAccount, + string seed, + VoteInitializeV2 initialize, + PublicKey inflationRewardsCollector, + PublicKey blockRevenueCollector, + ulong lamports, + ulong space = AccountDataLength) + => + [ + SystemProgram.CreateAccountWithSeed( + payer, + voteAccount, + baseAccount, + seed, + lamports, + space, + ProgramId), + InitializeAccountV2(voteAccount, initialize, inflationRewardsCollector, blockRevenueCollector) + ]; + + /// Changes a vote-account authority. + /// The writable vote account. + /// The current authority signer. + /// The replacement authority. + /// The role and optional BLS credentials. + /// The authorize instruction. + public static Instruction Authorize( + PublicKey voteAccount, + PublicKey currentAuthority, + PublicKey newAuthority, + VoteAuthorization authorization) + { + ArgumentNullException.ThrowIfNull(authorization); + authorization.ValidateProofForVoteAccount(voteAccount); + return CreateInstruction( + ProgramWireEncoding.Build(AuthorizeDiscriminator, stream => + { + ProgramWireEncoding.WritePublicKey(stream, newAuthority); + WriteAuthorization(stream, authorization); + }), + VoteAuthorityAccounts(voteAccount, currentAuthority, null)); + } + + /// Changes a vote-account authority and requires the replacement key to sign. + /// The writable vote account. + /// The current authority signer. + /// The replacement authority signer. + /// The role and optional BLS credentials. + /// The checked authorize instruction. + public static Instruction AuthorizeChecked( + PublicKey voteAccount, + PublicKey currentAuthority, + PublicKey newAuthority, + VoteAuthorization authorization) + { + ArgumentNullException.ThrowIfNull(authorization); + authorization.ValidateProofForVoteAccount(voteAccount); + return CreateInstruction( + ProgramWireEncoding.Build( + AuthorizeCheckedDiscriminator, + stream => WriteAuthorization(stream, authorization)), + VoteAuthorityAccounts(voteAccount, currentAuthority, newAuthority)); + } + + /// Changes a vote-account authority through a derived current authority. + /// The writable vote account. + /// The derivation base signer. + /// The derivation owner. + /// The derivation seed. + /// The replacement authority. + /// The role and optional BLS credentials. + /// The authorize-with-seed instruction. + public static Instruction AuthorizeWithSeed( + PublicKey voteAccount, + PublicKey currentAuthorityBase, + PublicKey currentAuthorityOwner, + string currentAuthoritySeed, + PublicKey newAuthority, + VoteAuthorization authorization) + { + ArgumentNullException.ThrowIfNull(authorization); + authorization.ValidateProofForVoteAccount(voteAccount); + var data = ProgramWireEncoding.Build(AuthorizeWithSeedDiscriminator, stream => + { + WriteAuthorization(stream, authorization); + ProgramWireEncoding.WritePublicKey(stream, currentAuthorityOwner); + ProgramWireEncoding.WriteString(stream, currentAuthoritySeed, nameof(currentAuthoritySeed)); + ProgramWireEncoding.WritePublicKey(stream, newAuthority); + }); + return CreateInstruction(data, VoteAuthorityAccounts(voteAccount, currentAuthorityBase, null)); + } + + /// Changes a derived vote authority and requires the replacement key to sign. + /// The writable vote account. + /// The derivation base signer. + /// The derivation owner. + /// The derivation seed. + /// The replacement authority signer. + /// The role and optional BLS credentials. + /// The checked authorize-with-seed instruction. + public static Instruction AuthorizeCheckedWithSeed( + PublicKey voteAccount, + PublicKey currentAuthorityBase, + PublicKey currentAuthorityOwner, + string currentAuthoritySeed, + PublicKey newAuthority, + VoteAuthorization authorization) + { + ArgumentNullException.ThrowIfNull(authorization); + authorization.ValidateProofForVoteAccount(voteAccount); + var data = ProgramWireEncoding.Build(AuthorizeCheckedWithSeedDiscriminator, stream => + { + WriteAuthorization(stream, authorization); + ProgramWireEncoding.WritePublicKey(stream, currentAuthorityOwner); + ProgramWireEncoding.WriteString(stream, currentAuthoritySeed, nameof(currentAuthoritySeed)); + }); + return CreateInstruction(data, VoteAuthorityAccounts(voteAccount, currentAuthorityBase, newAuthority)); + } + + /// Updates the validator identity stored in a vote account. + /// The writable vote account. + /// The withdrawal-authority signer. + /// The new validator-identity signer. + /// The identity-update instruction. + public static Instruction UpdateValidatorIdentity( + PublicKey voteAccount, + PublicKey withdrawAuthority, + PublicKey node) + => CreateInstruction( + ProgramWireEncoding.Build(UpdateValidatorIdentityDiscriminator), + [ + AccountMeta.Writable(voteAccount), + AccountMeta.ReadonlySigner(node), + AccountMeta.ReadonlySigner(withdrawAuthority) + ]); + + /// Updates the legacy commission percentage. + /// The writable vote account. + /// The withdrawal-authority signer. + /// The commission percentage. + /// The commission-update instruction. + public static Instruction UpdateCommission( + PublicKey voteAccount, + PublicKey withdrawAuthority, + byte commission) + => CreateInstruction( + ProgramWireEncoding.Build( + UpdateCommissionDiscriminator, + stream => ProgramWireEncoding.WriteByte(stream, commission)), + [AccountMeta.Writable(voteAccount), AccountMeta.ReadonlySigner(withdrawAuthority)]); + + /// Updates one commission collector. + /// The writable vote account. + /// The withdrawal-authority signer. + /// The writable replacement collector. + /// The commission stream to update. + /// The collector-update instruction. + public static Instruction UpdateCommissionCollector( + PublicKey voteAccount, + PublicKey withdrawAuthority, + PublicKey newCollector, + VoteCommissionKind kind) + { + ValidateCommissionKind(kind); + return CreateInstruction( + ProgramWireEncoding.Build( + UpdateCommissionCollectorDiscriminator, + stream => ProgramWireEncoding.WriteUInt32(stream, (uint)kind)), + [ + AccountMeta.Writable(voteAccount), + AccountMeta.Writable(newCollector), + AccountMeta.ReadonlySigner(withdrawAuthority) + ]); + } + + /// Updates one commission rate in basis points. + /// The writable vote account. + /// The withdrawal-authority signer. + /// The commission stream to update. + /// The replacement rate in basis points. + /// The basis-point commission instruction. + public static Instruction UpdateCommissionBps( + PublicKey voteAccount, + PublicKey withdrawAuthority, + VoteCommissionKind kind, + ushort commissionBps) + { + ValidateCommissionKind(kind); + return CreateInstruction( + ProgramWireEncoding.Build(UpdateCommissionBpsDiscriminator, stream => + { + ProgramWireEncoding.WriteUInt16(stream, commissionBps); + ProgramWireEncoding.WriteUInt32(stream, (uint)kind); + }), + [AccountMeta.Writable(voteAccount), AccountMeta.ReadonlySigner(withdrawAuthority)]); + } + + /// Deposits lamports for later distribution to stake delegators. + /// The writable vote account. + /// The writable funding signer. + /// The deposit amount. + /// The delegator-rewards deposit instruction. + public static Instruction DepositDelegatorRewards( + PublicKey voteAccount, + PublicKey source, + ulong lamports) + => CreateInstruction( + ProgramWireEncoding.Build( + DepositDelegatorRewardsDiscriminator, + stream => ProgramWireEncoding.WriteUInt64(stream, lamports)), + [AccountMeta.Writable(voteAccount), AccountMeta.WritableSigner(source)]); + + /// Submits a legacy vote containing recent slots. + /// The writable vote account. + /// The vote-authority signer. + /// The vote payload. + /// The vote instruction. + public static Instruction Vote(PublicKey voteAccount, PublicKey voteAuthority, VoteData vote) + => VoteInternal(VoteDiscriminator, voteAccount, voteAuthority, vote, null); + + /// Submits a legacy vote with a fork-switch proof hash. + /// The writable vote account. + /// The vote-authority signer. + /// The vote payload. + /// The fork-switch proof hash. + /// The vote-switch instruction. + public static Instruction VoteSwitch( + PublicKey voteAccount, + PublicKey voteAuthority, + VoteData vote, + Hash proofHash) + => VoteInternal(VoteSwitchDiscriminator, voteAccount, voteAuthority, vote, proofHash); + + /// Submits an ordinary bincode vote-state update. + /// The writable vote account. + /// The vote-authority signer. + /// The proposed tower update. + /// The update instruction. + public static Instruction UpdateVoteState( + PublicKey voteAccount, + PublicKey voteAuthority, + VoteStateUpdate update) + => VoteStateUpdateInternal(UpdateVoteStateDiscriminator, voteAccount, voteAuthority, update, null, compact: false); + + /// Submits an ordinary vote-state update with a fork-switch proof. + /// The writable vote account. + /// The vote-authority signer. + /// The proposed tower update. + /// The fork-switch proof hash. + /// The update-switch instruction. + public static Instruction UpdateVoteStateSwitch( + PublicKey voteAccount, + PublicKey voteAuthority, + VoteStateUpdate update, + Hash proofHash) + => VoteStateUpdateInternal( + UpdateVoteStateSwitchDiscriminator, + voteAccount, + voteAuthority, + update, + proofHash, + compact: false); + + /// Submits a compact, offset-encoded vote-state update. + /// The writable vote account. + /// The vote-authority signer. + /// The proposed tower update. + /// The compact update instruction. + public static Instruction CompactUpdateVoteState( + PublicKey voteAccount, + PublicKey voteAuthority, + VoteStateUpdate update) + => VoteStateUpdateInternal( + CompactUpdateVoteStateDiscriminator, + voteAccount, + voteAuthority, + update, + null, + compact: true); + + /// Submits a compact vote-state update with a fork-switch proof. + /// The writable vote account. + /// The vote-authority signer. + /// The proposed tower update. + /// The fork-switch proof hash. + /// The compact update-switch instruction. + public static Instruction CompactUpdateVoteStateSwitch( + PublicKey voteAccount, + PublicKey voteAuthority, + VoteStateUpdate update, + Hash proofHash) + => VoteStateUpdateInternal( + CompactUpdateVoteStateSwitchDiscriminator, + voteAccount, + voteAuthority, + update, + proofHash, + compact: true); + + /// Synchronizes on-chain vote state with a compact local tower. + /// The writable vote account. + /// The vote-authority signer. + /// The tower payload. + /// The tower-sync instruction. + public static Instruction TowerSync( + PublicKey voteAccount, + PublicKey voteAuthority, + VoteTowerSync tower) + => TowerSyncInternal(TowerSyncDiscriminator, voteAccount, voteAuthority, tower, null); + + /// Synchronizes on-chain vote state with a compact tower and fork-switch proof. + /// The writable vote account. + /// The vote-authority signer. + /// The tower payload. + /// The fork-switch proof hash. + /// The tower-sync-switch instruction. + public static Instruction TowerSyncSwitch( + PublicKey voteAccount, + PublicKey voteAuthority, + VoteTowerSync tower, + Hash proofHash) + => TowerSyncInternal(TowerSyncSwitchDiscriminator, voteAccount, voteAuthority, tower, proofHash); + + /// Withdraws lamports from a vote account. + /// The writable vote account. + /// The withdrawal-authority signer. + /// The amount to withdraw. + /// The writable recipient. + /// The withdraw instruction. + public static Instruction Withdraw( + PublicKey voteAccount, + PublicKey withdrawAuthority, + ulong lamports, + PublicKey recipient) + => CreateInstruction( + ProgramWireEncoding.Build( + WithdrawDiscriminator, + stream => ProgramWireEncoding.WriteUInt64(stream, lamports)), + [ + AccountMeta.Writable(voteAccount), + AccountMeta.Writable(recipient), + AccountMeta.ReadonlySigner(withdrawAuthority) + ]); + + private static Instruction VoteInternal( + uint discriminator, + PublicKey voteAccount, + PublicKey voteAuthority, + VoteData vote, + Hash? proofHash) + { + ArgumentNullException.ThrowIfNull(vote); + ArgumentNullException.ThrowIfNull(vote.Slots); + var data = ProgramWireEncoding.Build(discriminator, stream => + { + ProgramWireEncoding.WriteUInt64(stream, checked((ulong)vote.Slots.Count)); + foreach (var slot in vote.Slots) + ProgramWireEncoding.WriteUInt64(stream, slot); + ProgramWireEncoding.WriteHash(stream, vote.Hash); + ProgramWireEncoding.WriteOptionalInt64(stream, vote.Timestamp); + if (proofHash is { } hash) + ProgramWireEncoding.WriteHash(stream, hash); + }); + return CreateInstruction(data, VoteAccounts(voteAccount, voteAuthority)); + } + + private static Instruction VoteStateUpdateInternal( + uint discriminator, + PublicKey voteAccount, + PublicKey voteAuthority, + VoteStateUpdate update, + Hash? proofHash, + bool compact) + { + ArgumentNullException.ThrowIfNull(update); + ArgumentNullException.ThrowIfNull(update.Lockouts); + var data = ProgramWireEncoding.Build(discriminator, stream => + { + if (compact) + WriteCompactTower(stream, update.Lockouts, update.Root, update.Hash, update.Timestamp); + else + WriteVoteStateUpdate(stream, update); + if (proofHash is { } hash) + ProgramWireEncoding.WriteHash(stream, hash); + }); + return CreateInstruction(data, StateUpdateAccounts(voteAccount, voteAuthority)); + } + + private static Instruction TowerSyncInternal( + uint discriminator, + PublicKey voteAccount, + PublicKey voteAuthority, + VoteTowerSync tower, + Hash? proofHash) + { + ArgumentNullException.ThrowIfNull(tower); + ArgumentNullException.ThrowIfNull(tower.Lockouts); + var data = ProgramWireEncoding.Build(discriminator, stream => + { + WriteCompactTower(stream, tower.Lockouts, tower.Root, tower.Hash, tower.Timestamp); + ProgramWireEncoding.WriteHash(stream, tower.BlockId); + if (proofHash is { } hash) + ProgramWireEncoding.WriteHash(stream, hash); + }); + return CreateInstruction(data, StateUpdateAccounts(voteAccount, voteAuthority)); + } + + private static void WriteVoteStateUpdate(MemoryStream stream, VoteStateUpdate update) + { + ProgramWireEncoding.WriteUInt64(stream, checked((ulong)update.Lockouts.Count)); + foreach (var lockout in update.Lockouts) + { + ProgramWireEncoding.WriteUInt64(stream, lockout.Slot); + ProgramWireEncoding.WriteUInt32(stream, lockout.ConfirmationCount); + } + + ProgramWireEncoding.WriteOptionalUInt64(stream, update.Root); + ProgramWireEncoding.WriteHash(stream, update.Hash); + ProgramWireEncoding.WriteOptionalInt64(stream, update.Timestamp); + } + + private static void WriteCompactTower( + MemoryStream stream, + IReadOnlyList lockouts, + ulong? root, + Hash hash, + long? timestamp) + { + ProgramWireEncoding.WriteUInt64(stream, root ?? ulong.MaxValue); + ProgramWireEncoding.WriteShortVectorLength(stream, lockouts.Count); + + var previousSlot = root ?? 0; + foreach (var lockout in lockouts) + { + if (lockout.Slot < previousSlot) + { + throw new ArgumentException( + "Compact vote lockout slots must be ordered and must not precede the root.", + nameof(lockouts)); + } + + if (lockout.ConfirmationCount > byte.MaxValue) + { + throw new ArgumentException( + "A compact vote confirmation count must fit in one byte.", + nameof(lockouts)); + } + + ProgramWireEncoding.WriteUnsignedLeb128(stream, lockout.Slot - previousSlot); + ProgramWireEncoding.WriteByte(stream, (byte)lockout.ConfirmationCount); + previousSlot = lockout.Slot; + } + + ProgramWireEncoding.WriteHash(stream, hash); + ProgramWireEncoding.WriteOptionalInt64(stream, timestamp); + } + + private static void WriteAuthorization(MemoryStream stream, VoteAuthorization authorization) + { + ProgramWireEncoding.WriteUInt32(stream, (uint)authorization.Kind); + if (authorization.Kind == VoteAuthorizationKind.VoterWithBls) + { + stream.Write(authorization.BlsPublicKeySpan); + stream.Write(authorization.BlsProofOfPossessionSpan); + } + } + + private static IReadOnlyList VoteAuthorityAccounts( + PublicKey voteAccount, + PublicKey currentAuthority, + PublicKey? newAuthority) + => newAuthority is { } replacement + ? + [ + AccountMeta.Writable(voteAccount), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.ReadonlySigner(currentAuthority), + AccountMeta.ReadonlySigner(replacement) + ] + : + [ + AccountMeta.Writable(voteAccount), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.ReadonlySigner(currentAuthority) + ]; + + private static IReadOnlyList VoteAccounts(PublicKey voteAccount, PublicKey voteAuthority) + => + [ + AccountMeta.Writable(voteAccount), + AccountMeta.Readonly(SlotHashesSysvar), + AccountMeta.Readonly(ClockSysvar), + AccountMeta.ReadonlySigner(voteAuthority) + ]; + + private static IReadOnlyList StateUpdateAccounts( + PublicKey voteAccount, + PublicKey voteAuthority) + => [AccountMeta.Writable(voteAccount), AccountMeta.ReadonlySigner(voteAuthority)]; + + private static void ValidateCommissionKind(VoteCommissionKind kind) + { + if (kind is not VoteCommissionKind.InflationRewards and not VoteCommissionKind.BlockRevenue) + throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown vote commission kind."); + } + + private static Instruction CreateInstruction(byte[] data, IReadOnlyList accounts) + => new() { ProgramId = ProgramId, Accounts = accounts, Data = data }; +} diff --git a/src/SolSharp.Programs/VoteProgramModels.cs b/src/SolSharp.Programs/VoteProgramModels.cs new file mode 100644 index 0000000..2e94e1c --- /dev/null +++ b/src/SolSharp.Programs/VoteProgramModels.cs @@ -0,0 +1,346 @@ +using SolSharp.Core.Primitives; +using SolSharp.Wallet; + +namespace SolSharp.Programs; + +/// The commission stream selected by a vote-program instruction. +public enum VoteCommissionKind : uint +{ + /// Inflation rewards paid to the vote account. + InflationRewards = 0, + + /// Block revenue paid to the vote account. + BlockRevenue = 1 +} + +/// The vote-account authority role selected by an authorization instruction. +public enum VoteAuthorizationKind : uint +{ + /// The ordinary vote authority. + Voter = 0, + + /// The withdrawal authority. + Withdrawer = 1, + + /// A vote authority accompanied by a BLS public key and proof of possession. + VoterWithBls = 2 +} + +/// A vote authorization selector, including optional BLS credentials. +public sealed class VoteAuthorization +{ + private readonly byte[] _blsPublicKey; + private readonly byte[] _blsProofOfPossession; + private readonly BlsPublicKey? _validatedPublicKey; + private readonly BlsProofOfPossession? _validatedProof; + + private VoteAuthorization( + VoteAuthorizationKind kind, + ReadOnlySpan blsPublicKey, + ReadOnlySpan blsProofOfPossession) + : this(kind, blsPublicKey.ToArray(), blsProofOfPossession.ToArray(), null, null) + { + } + + private VoteAuthorization( + VoteAuthorizationKind kind, + byte[] blsPublicKey, + byte[] blsProofOfPossession, + BlsPublicKey? validatedPublicKey, + BlsProofOfPossession? validatedProof) + { + Kind = kind; + _blsPublicKey = blsPublicKey; + _blsProofOfPossession = blsProofOfPossession; + _validatedPublicKey = validatedPublicKey; + _validatedProof = validatedProof; + } + + /// The compressed BLS public-key length. + public const int BlsPublicKeyLength = 48; + + /// The compressed BLS proof-of-possession length. + public const int BlsProofOfPossessionLength = 96; + + /// Selects the ordinary vote authority. + public static VoteAuthorization Voter { get; } = new(VoteAuthorizationKind.Voter, [], []); + + /// Selects the withdrawal authority. + public static VoteAuthorization Withdrawer { get; } = new(VoteAuthorizationKind.Withdrawer, [], []); + + /// The selected authorization variant. + public VoteAuthorizationKind Kind { get; } + + /// + /// A defensive copy of the compressed BLS public key for . + /// + public ReadOnlyMemory BlsPublicKey => new([.. _blsPublicKey]); + + /// + /// A defensive copy of the BLS proof of possession for . + /// + public ReadOnlyMemory BlsProofOfPossession => new([.. _blsProofOfPossession]); + + internal ReadOnlySpan BlsPublicKeySpan => _blsPublicKey; + + internal ReadOnlySpan BlsProofOfPossessionSpan => _blsProofOfPossession; + + /// + /// Creates a low-level BLS-backed voter authorization variant. This overload validates lengths + /// only and deliberately performs no native point or proof verification. + /// + /// A 48-byte compressed BLS public key. + /// A 96-byte compressed BLS proof of possession. + /// The BLS-backed authorization selector. + /// Either input has the wrong length. + public static VoteAuthorization VoterWithBls( + ReadOnlySpan blsPublicKey, + ReadOnlySpan blsProofOfPossession) + { + ValidateLength(blsPublicKey, BlsPublicKeyLength, nameof(blsPublicKey)); + ValidateLength(blsProofOfPossession, BlsProofOfPossessionLength, nameof(blsProofOfPossession)); + return new VoteAuthorization(VoteAuthorizationKind.VoterWithBls, blsPublicKey, blsProofOfPossession); + } + + /// + /// Creates the BLS-backed voter authorization variant from validated BLS points. Vote instruction + /// builders verify that the proof matches both this key and the actual vote account before serialization. + /// + /// A canonical, subgroup-checked compressed BLS public key. + /// A canonical, subgroup-checked compressed proof of possession. + /// The BLS-backed authorization selector. + public static VoteAuthorization VoterWithBls( + BlsPublicKey blsPublicKey, + BlsProofOfPossession blsProofOfPossession) + { + ArgumentNullException.ThrowIfNull(blsPublicKey); + ArgumentNullException.ThrowIfNull(blsProofOfPossession); + return new VoteAuthorization( + VoteAuthorizationKind.VoterWithBls, + blsPublicKey.ToBytes(), + blsProofOfPossession.ToBytes(), + blsPublicKey, + blsProofOfPossession); + } + + internal void ValidateProofForVoteAccount(PublicKey voteAccount) + { + if (_validatedPublicKey is null || _validatedProof is null) + return; + + if (!_validatedPublicKey.VerifyVoteProofOfPossession(_validatedProof, voteAccount)) + { + throw new ArgumentException( + "The typed BLS proof of possession does not match the BLS public key and vote account.", + nameof(voteAccount)); + } + } + + private static void ValidateLength(ReadOnlySpan value, int expected, string parameterName) + { + if (value.Length != expected) + throw new ArgumentException($"Value must be exactly {expected} bytes, got {value.Length}.", parameterName); + } +} + +/// Initialization values for the legacy vote-account state. +/// The validator identity. +/// The initial vote authority. +/// The withdrawal authority. +/// The commission percentage. +public readonly record struct VoteInitialize( + PublicKey Node, + PublicKey AuthorizedVoter, + PublicKey AuthorizedWithdrawer, + byte Commission); + +/// Initialization values for vote state V4, including BLS credentials and basis-point commissions. +public sealed class VoteInitializeV2 +{ + private readonly byte[] _blsPublicKey; + private readonly byte[] _blsProofOfPossession; + private readonly BlsPublicKey? _validatedPublicKey; + private readonly BlsProofOfPossession? _validatedProof; + + /// + /// Creates low-level V4 vote-account initialization values. This overload validates lengths only + /// and deliberately performs no native point or proof verification. + /// + /// The validator identity. + /// The initial vote authority. + /// A 48-byte compressed BLS public key. + /// A 96-byte compressed BLS proof of possession. + /// The withdrawal authority. + /// Inflation commission in basis points. + /// Block-revenue commission in basis points. + public VoteInitializeV2( + PublicKey node, + PublicKey authorizedVoter, + ReadOnlySpan blsPublicKey, + ReadOnlySpan blsProofOfPossession, + PublicKey authorizedWithdrawer, + ushort inflationRewardsCommissionBps, + ushort blockRevenueCommissionBps) + : this( + node, + authorizedVoter, + CopyAndValidate(blsPublicKey, VoteAuthorization.BlsPublicKeyLength, nameof(blsPublicKey)), + CopyAndValidate( + blsProofOfPossession, + VoteAuthorization.BlsProofOfPossessionLength, + nameof(blsProofOfPossession)), + authorizedWithdrawer, + inflationRewardsCommissionBps, + blockRevenueCommissionBps, + null, + null) + { + } + + private VoteInitializeV2( + PublicKey node, + PublicKey authorizedVoter, + byte[] blsPublicKey, + byte[] blsProofOfPossession, + PublicKey authorizedWithdrawer, + ushort inflationRewardsCommissionBps, + ushort blockRevenueCommissionBps, + BlsPublicKey? validatedPublicKey, + BlsProofOfPossession? validatedProof) + { + Node = node; + AuthorizedVoter = authorizedVoter; + _blsPublicKey = blsPublicKey; + _blsProofOfPossession = blsProofOfPossession; + AuthorizedWithdrawer = authorizedWithdrawer; + InflationRewardsCommissionBps = inflationRewardsCommissionBps; + BlockRevenueCommissionBps = blockRevenueCommissionBps; + _validatedPublicKey = validatedPublicKey; + _validatedProof = validatedProof; + } + + /// + /// Creates V4 initialization values from validated BLS points. Vote instruction builders verify + /// that the proof matches both this key and the actual vote account before serialization. + /// + /// The validator identity. + /// The initial vote authority. + /// A canonical, subgroup-checked compressed BLS public key. + /// A canonical, subgroup-checked compressed proof of possession. + /// The withdrawal authority. + /// Inflation commission in basis points. + /// Block-revenue commission in basis points. + public VoteInitializeV2( + PublicKey node, + PublicKey authorizedVoter, + BlsPublicKey blsPublicKey, + BlsProofOfPossession blsProofOfPossession, + PublicKey authorizedWithdrawer, + ushort inflationRewardsCommissionBps, + ushort blockRevenueCommissionBps) + : this( + node, + authorizedVoter, + CopyPublicKey(blsPublicKey), + CopyProof(blsProofOfPossession), + authorizedWithdrawer, + inflationRewardsCommissionBps, + blockRevenueCommissionBps, + RequirePublicKey(blsPublicKey), + RequireProof(blsProofOfPossession)) + { + } + + /// The validator identity. + public PublicKey Node { get; } + + /// The initial vote authority. + public PublicKey AuthorizedVoter { get; } + + /// A defensive copy of the compressed BLS public key. + public ReadOnlyMemory BlsPublicKey => new([.. _blsPublicKey]); + + /// A defensive copy of the compressed BLS proof of possession. + public ReadOnlyMemory BlsProofOfPossession => new([.. _blsProofOfPossession]); + + internal ReadOnlySpan BlsPublicKeySpan => _blsPublicKey; + + internal ReadOnlySpan BlsProofOfPossessionSpan => _blsProofOfPossession; + + /// The withdrawal authority. + public PublicKey AuthorizedWithdrawer { get; } + + /// The inflation commission in basis points. + public ushort InflationRewardsCommissionBps { get; } + + /// The block-revenue commission in basis points. + public ushort BlockRevenueCommissionBps { get; } + + internal void ValidateProofForVoteAccount(PublicKey voteAccount) + { + if (_validatedPublicKey is null || _validatedProof is null) + return; + + if (!_validatedPublicKey.VerifyVoteProofOfPossession(_validatedProof, voteAccount)) + { + throw new ArgumentException( + "The typed BLS proof of possession does not match the BLS public key and vote account.", + nameof(voteAccount)); + } + } + + private static byte[] CopyAndValidate(ReadOnlySpan value, int expectedLength, string parameterName) + { + if (value.Length != expectedLength) + throw new ArgumentException($"Value must be exactly {expectedLength} bytes.", parameterName); + + return value.ToArray(); + } + + private static byte[] CopyPublicKey(BlsPublicKey publicKey) => + RequirePublicKey(publicKey).ToBytes(); + + private static byte[] CopyProof(BlsProofOfPossession proof) => + RequireProof(proof).ToBytes(); + + private static BlsPublicKey RequirePublicKey(BlsPublicKey publicKey) => + publicKey ?? throw new ArgumentNullException(nameof(publicKey)); + + private static BlsProofOfPossession RequireProof(BlsProofOfPossession proof) => + proof ?? throw new ArgumentNullException(nameof(proof)); +} + +/// A slot and its tower confirmation count. +/// The voted slot. +/// The confirmation count. +public readonly record struct VoteLockout(ulong Slot, uint ConfirmationCount); + +/// A legacy vote payload containing recent slots. +/// Slots ordered from oldest to newest. +/// The bank hash for the last slot. +/// An optional processing timestamp. +public sealed record VoteData(IReadOnlyList Slots, Hash Hash, long? Timestamp = null); + +/// A proposed on-chain vote-state update. +/// Tower lockouts ordered from oldest to newest. +/// The proposed root slot. +/// The bank hash for the last slot. +/// An optional processing timestamp. +public sealed record VoteStateUpdate( + IReadOnlyList Lockouts, + ulong? Root, + Hash Hash, + long? Timestamp = null); + +/// A compact tower synchronization payload. +/// Tower lockouts ordered from oldest to newest. +/// The proposed root slot. +/// The bank hash for the last slot. +/// The unique identifier of the chain through the block. +/// An optional processing timestamp. +public sealed record VoteTowerSync( + IReadOnlyList Lockouts, + ulong? Root, + Hash Hash, + Hash BlockId, + long? Timestamp = null); diff --git a/src/SolSharp.Programs/VoteStateBincodeReader.cs b/src/SolSharp.Programs/VoteStateBincodeReader.cs new file mode 100644 index 0000000..b32950a --- /dev/null +++ b/src/SolSharp.Programs/VoteStateBincodeReader.cs @@ -0,0 +1,76 @@ +using System.Buffers.Binary; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +internal ref struct VoteStateBincodeReader(ReadOnlySpan data) +{ + private readonly ReadOnlySpan _data = data; + private int _offset; + + public byte ReadByte() => ReadBytes(sizeof(byte))[0]; + + public bool ReadBool() + { + var value = ReadByte(); + return value switch + { + 0 => false, + 1 => true, + _ => throw Invalid("Boolean values must use the canonical 0 or 1 encoding.") + }; + } + + public ushort ReadUInt16() => BinaryPrimitives.ReadUInt16LittleEndian(ReadBytes(sizeof(ushort))); + + public uint ReadUInt32() => BinaryPrimitives.ReadUInt32LittleEndian(ReadBytes(sizeof(uint))); + + public ulong ReadUInt64() => BinaryPrimitives.ReadUInt64LittleEndian(ReadBytes(sizeof(ulong))); + + public long ReadInt64() => BinaryPrimitives.ReadInt64LittleEndian(ReadBytes(sizeof(long))); + + public PublicKey ReadPublicKey() => new(ReadBytes(PublicKey.Length)); + + public ReadOnlyMemory? ReadOptionalBlsPublicKey() + { + var tag = ReadByte(); + return tag switch + { + 0 => null, + 1 => ReadBytes(VoteStateVersions.BlsPublicKeyLength).ToArray(), + _ => throw Invalid("The optional BLS public-key tag must be 0 or 1.") + }; + } + + public ulong? ReadOptionalUInt64() + { + var tag = ReadByte(); + return tag switch + { + 0 => null, + 1 => ReadUInt64(), + _ => throw Invalid("The optional slot tag must be 0 or 1.") + }; + } + + public int ReadBoundedCount(int maximum, string collectionName) + { + var count = ReadUInt64(); + if (count > (ulong)maximum) + throw Invalid($"{collectionName} count {count} exceeds the maximum of {maximum}."); + + return (int)count; + } + + private ReadOnlySpan ReadBytes(int length) + { + if (length > _data.Length - _offset) + throw Invalid("Vote account data is truncated."); + + var result = _data.Slice(_offset, length); + _offset += length; + return result; + } + + private static ArgumentException Invalid(string message) => new(message); +} diff --git a/src/SolSharp.Programs/VoteStateLegacyVersions.cs b/src/SolSharp.Programs/VoteStateLegacyVersions.cs new file mode 100644 index 0000000..b2f5aa4 --- /dev/null +++ b/src/SolSharp.Programs/VoteStateLegacyVersions.cs @@ -0,0 +1,209 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// A decoded vote state using the V1.14.11 layout. +public sealed record VoteStateV1_14_11 : VoteStateVersions +{ + /// The exact pinned vote-account allocation. + public const int DataLength = 3_731; + + private VoteStateV1_14_11( + PublicKey node, + PublicKey authorizedWithdrawer, + byte commission, + IReadOnlyList votes, + ulong? rootSlot, + IReadOnlyList authorizedVoters, + IReadOnlyList priorVoters, + int priorVoterIndex, + bool priorVotersEmpty, + IReadOnlyList epochCredits, + VoteStateTimestamp lastTimestamp) + { + Node = node; + AuthorizedWithdrawer = authorizedWithdrawer; + Commission = commission; + Votes = votes; + RootSlot = rootSlot; + AuthorizedVoters = authorizedVoters; + PriorVoters = priorVoters; + PriorVoterIndex = priorVoterIndex; + PriorVotersEmpty = priorVotersEmpty; + EpochCredits = epochCredits; + LastTimestamp = lastTimestamp; + } + + /// + public override VoteStateVersion Version => VoteStateVersion.V1_14_11; + + /// + public override PublicKey Node { get; } + + /// + public override PublicKey AuthorizedWithdrawer { get; } + + /// The legacy reward commission percentage. + public byte Commission { get; } + + /// + public override IReadOnlyList Votes { get; } + + /// + public override ulong? RootSlot { get; } + + /// + public override IReadOnlyList AuthorizedVoters { get; } + + /// All 32 serialized entries of the prior-voter circular buffer. + public IReadOnlyList PriorVoters { get; } + + /// The next position recorded by the prior-voter circular buffer. + public int PriorVoterIndex { get; } + + /// Whether the prior-voter circular buffer is empty. + public bool PriorVotersEmpty { get; } + + /// + public override IReadOnlyList EpochCredits { get; } + + /// + public override VoteStateTimestamp LastTimestamp { get; } + + /// + public override bool IsUninitialized => AuthorizedVoters.Count is 0; + + /// Checks the V1.14.11 exact allocation and initialization sentinel used by the pinned SDK. + /// The complete account allocation. + /// true when the allocation has the exact size and nonzero initialization prefix. + public static new bool IsCorrectSizeAndInitialized(ReadOnlySpan data) => + data.Length == DataLength && HasAnyNonzero(data, sizeof(uint), 82); + + internal static VoteStateV1_14_11 ParseBody(ref VoteStateBincodeReader reader) + { + var node = reader.ReadPublicKey(); + var withdrawer = reader.ReadPublicKey(); + var commission = reader.ReadByte(); + var votes = ReadVotes(ref reader, hasLatency: false); + var root = reader.ReadOptionalUInt64(); + var authorizedVoters = ReadAuthorizedVoters(ref reader); + var priorVoters = ReadPriorVoters(ref reader, out var priorVoterIndex, out var priorVotersEmpty); + var epochCredits = ReadEpochCredits(ref reader); + var timestamp = ReadTimestamp(ref reader); + return new VoteStateV1_14_11( + node, + withdrawer, + commission, + votes, + root, + authorizedVoters, + priorVoters, + priorVoterIndex, + priorVotersEmpty, + epochCredits, + timestamp); + } +} + +/// A decoded vote state using the latency-aware V3 layout. +public sealed record VoteStateV3 : VoteStateVersions +{ + /// The exact pinned vote-account allocation. + public const int DataLength = 3_762; + + private VoteStateV3( + PublicKey node, + PublicKey authorizedWithdrawer, + byte commission, + IReadOnlyList votes, + ulong? rootSlot, + IReadOnlyList authorizedVoters, + IReadOnlyList priorVoters, + int priorVoterIndex, + bool priorVotersEmpty, + IReadOnlyList epochCredits, + VoteStateTimestamp lastTimestamp) + { + Node = node; + AuthorizedWithdrawer = authorizedWithdrawer; + Commission = commission; + Votes = votes; + RootSlot = rootSlot; + AuthorizedVoters = authorizedVoters; + PriorVoters = priorVoters; + PriorVoterIndex = priorVoterIndex; + PriorVotersEmpty = priorVotersEmpty; + EpochCredits = epochCredits; + LastTimestamp = lastTimestamp; + } + + /// + public override VoteStateVersion Version => VoteStateVersion.V3; + + /// + public override PublicKey Node { get; } + + /// + public override PublicKey AuthorizedWithdrawer { get; } + + /// The legacy reward commission percentage. + public byte Commission { get; } + + /// + public override IReadOnlyList Votes { get; } + + /// + public override ulong? RootSlot { get; } + + /// + public override IReadOnlyList AuthorizedVoters { get; } + + /// All 32 serialized entries of the prior-voter circular buffer. + public IReadOnlyList PriorVoters { get; } + + /// The next position recorded by the prior-voter circular buffer. + public int PriorVoterIndex { get; } + + /// Whether the prior-voter circular buffer is empty. + public bool PriorVotersEmpty { get; } + + /// + public override IReadOnlyList EpochCredits { get; } + + /// + public override VoteStateTimestamp LastTimestamp { get; } + + /// + public override bool IsUninitialized => AuthorizedVoters.Count is 0; + + /// Checks the V3 exact allocation and initialization sentinel used by the pinned SDK. + /// The complete account allocation. + /// true when the allocation has the exact size and nonzero initialization prefix. + public static new bool IsCorrectSizeAndInitialized(ReadOnlySpan data) => + data.Length == DataLength && HasAnyNonzero(data, sizeof(uint), 114); + + internal static VoteStateV3 ParseBody(ref VoteStateBincodeReader reader) + { + var node = reader.ReadPublicKey(); + var withdrawer = reader.ReadPublicKey(); + var commission = reader.ReadByte(); + var votes = ReadVotes(ref reader, hasLatency: true); + var root = reader.ReadOptionalUInt64(); + var authorizedVoters = ReadAuthorizedVoters(ref reader); + var priorVoters = ReadPriorVoters(ref reader, out var priorVoterIndex, out var priorVotersEmpty); + var epochCredits = ReadEpochCredits(ref reader); + var timestamp = ReadTimestamp(ref reader); + return new VoteStateV3( + node, + withdrawer, + commission, + votes, + root, + authorizedVoters, + priorVoters, + priorVoterIndex, + priorVotersEmpty, + epochCredits, + timestamp); + } +} diff --git a/src/SolSharp.Programs/VoteStateV4.cs b/src/SolSharp.Programs/VoteStateV4.cs new file mode 100644 index 0000000..8ec70eb --- /dev/null +++ b/src/SolSharp.Programs/VoteStateV4.cs @@ -0,0 +1,125 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// A decoded vote state using the collector- and BLS-aware V4 layout. +public sealed record VoteStateV4 : VoteStateVersions +{ + /// The exact pinned vote-account allocation, retained from V3. + public const int DataLength = 3_762; + + private VoteStateV4( + PublicKey node, + PublicKey authorizedWithdrawer, + PublicKey inflationRewardsCollector, + PublicKey blockRevenueCollector, + ushort inflationRewardsCommissionBasisPoints, + ushort blockRevenueCommissionBasisPoints, + ulong pendingDelegatorRewards, + ReadOnlyMemory? blsPublicKey, + IReadOnlyList votes, + ulong? rootSlot, + IReadOnlyList authorizedVoters, + IReadOnlyList epochCredits, + VoteStateTimestamp lastTimestamp) + { + Node = node; + AuthorizedWithdrawer = authorizedWithdrawer; + InflationRewardsCollector = inflationRewardsCollector; + BlockRevenueCollector = blockRevenueCollector; + InflationRewardsCommissionBasisPoints = inflationRewardsCommissionBasisPoints; + BlockRevenueCommissionBasisPoints = blockRevenueCommissionBasisPoints; + PendingDelegatorRewards = pendingDelegatorRewards; + BlsPublicKey = blsPublicKey; + Votes = votes; + RootSlot = rootSlot; + AuthorizedVoters = authorizedVoters; + EpochCredits = epochCredits; + LastTimestamp = lastTimestamp; + } + + /// + public override VoteStateVersion Version => VoteStateVersion.V4; + + /// + public override PublicKey Node { get; } + + /// + public override PublicKey AuthorizedWithdrawer { get; } + + /// The collector of inflation rewards. + public PublicKey InflationRewardsCollector { get; } + + /// The collector of block revenue. + public PublicKey BlockRevenueCollector { get; } + + /// The inflation-reward commission in basis points. + public ushort InflationRewardsCommissionBasisPoints { get; } + + /// The block-revenue commission in basis points. + public ushort BlockRevenueCommissionBasisPoints { get; } + + /// The rewards pending distribution to stake delegators. + public ulong PendingDelegatorRewards { get; } + + /// The optional 48-byte compressed BLS public key. + public ReadOnlyMemory? BlsPublicKey { get; } + + /// + public override IReadOnlyList Votes { get; } + + /// + public override ulong? RootSlot { get; } + + /// + public override IReadOnlyList AuthorizedVoters { get; } + + /// + public override IReadOnlyList EpochCredits { get; } + + /// + public override VoteStateTimestamp LastTimestamp { get; } + + /// + public override bool IsUninitialized => false; + + /// Checks the exact V4 allocation and its raw little-endian discriminant. + /// The complete account allocation. + /// true when the allocation has the exact size and V4 discriminant. + public static new bool IsCorrectSizeAndInitialized(ReadOnlySpan data) + { + ReadOnlySpan tag = [3, 0, 0, 0]; + return data.Length == DataLength && data[..sizeof(uint)].SequenceEqual(tag); + } + + internal static VoteStateV4 ParseBody(ref VoteStateBincodeReader reader) + { + var node = reader.ReadPublicKey(); + var withdrawer = reader.ReadPublicKey(); + var inflationCollector = reader.ReadPublicKey(); + var blockCollector = reader.ReadPublicKey(); + var inflationCommission = reader.ReadUInt16(); + var blockCommission = reader.ReadUInt16(); + var pendingRewards = reader.ReadUInt64(); + var blsPublicKey = reader.ReadOptionalBlsPublicKey(); + var votes = ReadVotes(ref reader, hasLatency: true); + var root = reader.ReadOptionalUInt64(); + var authorizedVoters = ReadAuthorizedVoters(ref reader); + var epochCredits = ReadEpochCredits(ref reader); + var timestamp = ReadTimestamp(ref reader); + return new VoteStateV4( + node, + withdrawer, + inflationCollector, + blockCollector, + inflationCommission, + blockCommission, + pendingRewards, + blsPublicKey, + votes, + root, + authorizedVoters, + epochCredits, + timestamp); + } +} diff --git a/src/SolSharp.Programs/VoteStateVersions.cs b/src/SolSharp.Programs/VoteStateVersions.cs new file mode 100644 index 0000000..5265420 --- /dev/null +++ b/src/SolSharp.Programs/VoteStateVersions.cs @@ -0,0 +1,181 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs; + +/// The supported discriminants of the pinned vote-interface VoteStateVersions enum. +public enum VoteStateVersion : uint +{ + /// The legacy 1.14.11 state layout. + V1_14_11 = 1, + + /// The latency-aware V3 state layout. + V3 = 2, + + /// The collector- and BLS-aware V4 state layout. + V4 = 3 +} + +/// A vote lockout decoded from vote-account state. +/// The landing latency; zero for the V1.14.11 layout. +/// The voted slot. +/// The tower confirmation count. +public readonly record struct VoteStateLockout(byte Latency, ulong Slot, uint ConfirmationCount); + +/// An epoch-keyed authorized vote signer. +/// The epoch at which this voter became authorized. +/// The authorized voter address. +public readonly record struct AuthorizedVoteVoter(ulong Epoch, PublicKey Voter); + +/// A fixed circular-buffer entry for a prior vote authority. +/// The prior authority. +/// The inclusive first authorized epoch. +/// The exclusive last authorized epoch. +public readonly record struct PriorVoteVoter(PublicKey Voter, ulong FromEpoch, ulong UntilEpoch); + +/// Vote credits accumulated for an epoch. +/// The epoch. +/// The total credits at the end of the epoch. +/// The credits carried into the epoch. +public readonly record struct VoteEpochCredits(ulong Epoch, ulong Credits, ulong PreviousCredits); + +/// The most recent timestamp submitted to a vote account. +/// The timestamped slot. +/// The submitted Unix timestamp. +public readonly record struct VoteStateTimestamp(ulong Slot, long UnixTimestamp); + +/// A strictly bounded decoded variant of the pinned vote-interface VoteStateVersions. +public abstract record VoteStateVersions +{ + /// The maximum number of tower lockouts retained by the runtime. + public const int MaximumLockouts = 31; + + /// The maximum number of epoch-credit entries retained by the runtime. + public const int MaximumEpochCredits = 64; + + /// The fixed number of prior-voter circular-buffer entries in V1.14.11 and V3. + public const int PriorVoterEntries = 32; + + /// The maximum serialized authorized-voter entries used by current vote states. + public const int MaximumAuthorizedVoters = 4; + + /// The compressed BLS public-key length. + public const int BlsPublicKeyLength = 48; + + /// The raw little-endian bincode enum tag. + public abstract VoteStateVersion Version { get; } + + /// The validator identity. + public abstract PublicKey Node { get; } + + /// The vote account's withdrawal authority. + public abstract PublicKey AuthorizedWithdrawer { get; } + + /// The ordered tower lockouts. + public abstract IReadOnlyList Votes { get; } + + /// The rooted slot, when present. + public abstract ulong? RootSlot { get; } + + /// The epoch-keyed authorized voters. + public abstract IReadOnlyList AuthorizedVoters { get; } + + /// The vote-credit history. + public abstract IReadOnlyList EpochCredits { get; } + + /// The last submitted timestamp. + public abstract VoteStateTimestamp LastTimestamp { get; } + + /// Whether the decoded variant is the vote-program's uninitialized sentinel. + public abstract bool IsUninitialized { get; } + + /// Decodes a V1.14.11, V3, or V4 vote account without coercing its version. + /// Vote-account data beginning with the four-byte bincode enum discriminant. + /// The exact decoded variant. + /// The tag, fields, option values, or bounded collection counts are invalid. + public static VoteStateVersions Parse(ReadOnlySpan data) + { + var reader = new VoteStateBincodeReader(data); + var tag = reader.ReadUInt32(); + return tag switch + { + (uint)VoteStateVersion.V1_14_11 => VoteStateV1_14_11.ParseBody(ref reader), + (uint)VoteStateVersion.V3 => VoteStateV3.ParseBody(ref reader), + (uint)VoteStateVersion.V4 => VoteStateV4.ParseBody(ref reader), + _ => throw new ArgumentException($"Unsupported vote-state tag {tag}.", nameof(data)) + }; + } + + /// Checks the exact account allocation and upstream initialization sentinel without decoding collections. + /// The complete vote-account data allocation. + /// true for an initialized V1.14.11, V3, or V4 allocation of the exact pinned size. + public static bool IsCorrectSizeAndInitialized(ReadOnlySpan data) => + VoteStateV4.IsCorrectSizeAndInitialized(data) || + VoteStateV3.IsCorrectSizeAndInitialized(data) || + VoteStateV1_14_11.IsCorrectSizeAndInitialized(data); + + internal static IReadOnlyList ReadVotes(ref VoteStateBincodeReader reader, bool hasLatency) + { + var count = reader.ReadBoundedCount(MaximumLockouts, "Vote lockout"); + var votes = new VoteStateLockout[count]; + for (var i = 0; i < votes.Length; i++) + { + var latency = hasLatency ? reader.ReadByte() : (byte)0; + votes[i] = new VoteStateLockout(latency, reader.ReadUInt64(), reader.ReadUInt32()); + } + + return votes; + } + + internal static IReadOnlyList ReadAuthorizedVoters(ref VoteStateBincodeReader reader) + { + var count = reader.ReadBoundedCount(MaximumAuthorizedVoters, "Authorized voter"); + var voters = new AuthorizedVoteVoter[count]; + for (var i = 0; i < voters.Length; i++) + voters[i] = new AuthorizedVoteVoter(reader.ReadUInt64(), reader.ReadPublicKey()); + return voters; + } + + internal static IReadOnlyList ReadPriorVoters( + ref VoteStateBincodeReader reader, + out int index, + out bool isEmpty) + { + var voters = new PriorVoteVoter[PriorVoterEntries]; + for (var i = 0; i < voters.Length; i++) + voters[i] = new PriorVoteVoter(reader.ReadPublicKey(), reader.ReadUInt64(), reader.ReadUInt64()); + + var rawIndex = reader.ReadUInt64(); + if (rawIndex >= PriorVoterEntries) + throw new ArgumentException($"Prior-voter index {rawIndex} is outside the fixed circular buffer."); + + index = (int)rawIndex; + isEmpty = reader.ReadBool(); + return voters; + } + + internal static IReadOnlyList ReadEpochCredits(ref VoteStateBincodeReader reader) + { + var count = reader.ReadBoundedCount(MaximumEpochCredits, "Epoch credits"); + var credits = new VoteEpochCredits[count]; + for (var i = 0; i < credits.Length; i++) + credits[i] = new VoteEpochCredits(reader.ReadUInt64(), reader.ReadUInt64(), reader.ReadUInt64()); + return credits; + } + + internal static VoteStateTimestamp ReadTimestamp(ref VoteStateBincodeReader reader) => + new(reader.ReadUInt64(), reader.ReadInt64()); + + internal static bool HasAnyNonzero(ReadOnlySpan data, int offset, int length) + { + if (data.Length < offset + length) + return false; + + foreach (var value in data.Slice(offset, length)) + { + if (value is not 0) + return true; + } + + return false; + } +} diff --git a/src/SolSharp.Rpc/AccountFilter.cs b/src/SolSharp.Rpc/AccountFilter.cs index 81d3b35..011615e 100644 --- a/src/SolSharp.Rpc/AccountFilter.cs +++ b/src/SolSharp.Rpc/AccountFilter.cs @@ -1,14 +1,20 @@ using System.Text.Json.Serialization; +using SolSharp.Core.Encoding; namespace SolSharp.Rpc; /// /// A getProgramAccounts / programSubscribe filter. Build one with -/// (a memcmp match at an offset) or (an exact data-length match); an account -/// must satisfy every supplied filter to be returned. +/// (the legacy base58 memcmp factory), one of the explicitly encoded memcmp factories, +/// , or . An account must satisfy every supplied +/// filter to be returned. /// public sealed class AccountFilter { + private const int MaxMemoryCompareBytes = 128; + private const int MaxBase58Length = 175; + private const int MaxBase64Length = 172; + private AccountFilter(object payload) => Payload = payload; internal object Payload { get; } @@ -17,14 +23,150 @@ public sealed class AccountFilter /// The byte offset into the account data to compare from. /// The bytes to match, base58-encoded. /// The filter. - public static AccountFilter MemoryCompare(int offset, string bytesBase58) => - new(new MemcmpFilter { Memcmp = new MemcmpMatch { Offset = offset, Bytes = bytesBase58, Encoding = "base58" } }); + /// is negative. + /// is null. + /// + /// is not valid base58 or decodes to more than 128 bytes. + /// + public static AccountFilter MemoryCompare(int offset, string bytesBase58) + { + if (offset < 0) + throw new ArgumentOutOfRangeException(nameof(offset), offset, "The memory-compare offset cannot be negative."); + + return MemoryCompareBase58((ulong)offset, bytesBase58); + } + + /// Matches base58-encoded bytes at an unsigned 64-bit account-data offset. + /// The byte offset into the account data to compare from. + /// The bytes to match, base58-encoded. + /// The filter. + /// is null. + /// + /// is not valid base58 or decodes to more than 128 bytes. + /// + public static AccountFilter MemoryCompareBase58(ulong offset, string bytesBase58) + { + ValidateBase58(bytesBase58); + return EncodedMemoryCompare(offset, bytesBase58, "base58"); + } + + /// Matches base64-encoded bytes at an unsigned 64-bit account-data offset. + /// The byte offset into the account data to compare from. + /// The bytes to match, canonical base64-encoded. + /// The filter. + /// is null. + /// + /// is not canonical base64 or decodes to more than 128 bytes. + /// + public static AccountFilter MemoryCompareBase64(ulong offset, string bytesBase64) + { + ValidateBase64(bytesBase64); + return EncodedMemoryCompare(offset, bytesBase64, "base64"); + } + + /// Matches raw bytes at an unsigned 64-bit account-data offset. + /// The byte offset into the account data to compare from. + /// The raw bytes to match. + /// The filter. + /// contains more than 128 bytes. + public static AccountFilter MemoryCompareRaw(ulong offset, ReadOnlySpan bytes) + { + if (bytes.Length > MaxMemoryCompareBytes) + { + throw new ArgumentException( + $"Memory-compare data cannot exceed {MaxMemoryCompareBytes} bytes.", nameof(bytes)); + } + + return new AccountFilter( + new RawMemcmpFilter + { + Memcmp = new RawMemcmpMatch + { + Offset = offset, + Bytes = bytes.ToArray(), + Encoding = "bytes" + } + }); + } /// Matches accounts whose data is exactly bytes long (a dataSize filter). /// The required account data length in bytes. /// The filter. - public static AccountFilter DataSize(long size) => + /// is negative. + public static AccountFilter DataSize(long size) + { + if (size < 0) + throw new ArgumentOutOfRangeException(nameof(size), size, "The account-data size cannot be negative."); + + return DataSizeUnsigned((ulong)size); + } + + /// Matches an exact account-data length across the full unsigned 64-bit upstream range. + /// The required account data length in bytes. + /// The filter. + public static AccountFilter DataSizeUnsigned(ulong size) => new(new DataSizeFilter { DataSize = size }); + + /// Matches data with a valid SPL Token or Token-2022 account state layout. + /// The upstream tokenAccountState filter. + public static AccountFilter TokenAccountState() => new("tokenAccountState"); + + private static AccountFilter EncodedMemoryCompare(ulong offset, string bytes, string encoding) => + new(new MemcmpFilter + { + Memcmp = new MemcmpMatch { Offset = offset, Bytes = bytes, Encoding = encoding } + }); + + private static void ValidateBase58(string bytesBase58) + { + ArgumentNullException.ThrowIfNull(bytesBase58); + if (bytesBase58.Length > MaxBase58Length) + throw MemoryCompareTooLarge(nameof(bytesBase58)); + + byte[] decoded; + try + { + decoded = Base58.Decode(bytesBase58); + } + catch (FormatException exception) + { + throw new ArgumentException("Memory-compare data must be valid base58.", nameof(bytesBase58), exception); + } + + if (decoded.Length > MaxMemoryCompareBytes) + throw MemoryCompareTooLarge(nameof(bytesBase58)); + } + + private static void ValidateBase64(string bytesBase64) + { + ArgumentNullException.ThrowIfNull(bytesBase64); + if (bytesBase64.Length > MaxBase64Length) + throw MemoryCompareTooLarge(nameof(bytesBase64)); + + for (var i = 0; i < bytesBase64.Length; i++) + { + if (char.IsWhiteSpace(bytesBase64[i])) + throw new ArgumentException("Memory-compare data must be canonical base64.", nameof(bytesBase64)); + } + + byte[] decoded; + try + { + decoded = Convert.FromBase64String(bytesBase64); + } + catch (FormatException exception) + { + throw new ArgumentException("Memory-compare data must be valid base64.", nameof(bytesBase64), exception); + } + + if (!string.Equals(Convert.ToBase64String(decoded), bytesBase64, StringComparison.Ordinal)) + throw new ArgumentException("Memory-compare data must be canonical base64.", nameof(bytesBase64)); + if (decoded.Length > MaxMemoryCompareBytes) + throw MemoryCompareTooLarge(nameof(bytesBase64)); + } + + private static ArgumentException MemoryCompareTooLarge(string parameterName) => + new($"Memory-compare data cannot exceed {MaxMemoryCompareBytes} decoded bytes.", parameterName); } /// The { memcmp: { offset, bytes, encoding } } wire shape of a memcmp filter entry. @@ -38,7 +180,7 @@ internal sealed record MemcmpFilter internal sealed record MemcmpMatch { [JsonPropertyName("offset")] - public required int Offset { get; init; } + public required ulong Offset { get; init; } [JsonPropertyName("bytes")] public required string Bytes { get; init; } @@ -47,9 +189,29 @@ internal sealed record MemcmpMatch public required string Encoding { get; init; } } +/// The { memcmp: { offset, bytes: [...], encoding: "bytes" } } raw-byte filter shape. +internal sealed record RawMemcmpFilter +{ + [JsonPropertyName("memcmp")] + public required RawMemcmpMatch Memcmp { get; init; } +} + +/// The raw-byte body of a . +internal sealed record RawMemcmpMatch +{ + [JsonPropertyName("offset")] + public required ulong Offset { get; init; } + + [JsonPropertyName("bytes")] + public required IReadOnlyList Bytes { get; init; } + + [JsonPropertyName("encoding")] + public required string Encoding { get; init; } +} + /// The { dataSize } wire shape of a data-size filter entry. internal sealed record DataSizeFilter { [JsonPropertyName("dataSize")] - public required long DataSize { get; init; } + public required ulong DataSize { get; init; } } diff --git a/src/SolSharp.Rpc/DataSlice.cs b/src/SolSharp.Rpc/DataSlice.cs index e458223..8948048 100644 --- a/src/SolSharp.Rpc/DataSlice.cs +++ b/src/SolSharp.Rpc/DataSlice.cs @@ -11,6 +11,6 @@ namespace SolSharp.Rpc; /// The number of bytes to return. public sealed record DataSlice( [property: JsonPropertyName("offset")] - int Offset, + ulong Offset, [property: JsonPropertyName("length")] - int Length); + ulong Length); diff --git a/src/SolSharp.Rpc/GetAccountInfoOptions.cs b/src/SolSharp.Rpc/GetAccountInfoOptions.cs new file mode 100644 index 0000000..cd57216 --- /dev/null +++ b/src/SolSharp.Rpc/GetAccountInfoOptions.cs @@ -0,0 +1,19 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc; + +/// +/// Options for account-info RPC reads, including getAccountInfo, +/// getMultipleAccounts, and token-account scans. Unset fields use the node defaults. +/// +public sealed record GetAccountInfoOptions +{ + /// The commitment level to query at. + public Commitment? Commitment { get; init; } + + /// Return only this slice of each account's data; return all data when null. + public DataSlice? DataSlice { get; init; } + + /// The minimum slot at which the request may be evaluated. + public ulong? MinContextSlot { get; init; } +} diff --git a/src/SolSharp.Rpc/GetProgramAccountsOptions.cs b/src/SolSharp.Rpc/GetProgramAccountsOptions.cs index e8aa387..a473f1a 100644 --- a/src/SolSharp.Rpc/GetProgramAccountsOptions.cs +++ b/src/SolSharp.Rpc/GetProgramAccountsOptions.cs @@ -8,7 +8,7 @@ public sealed record GetProgramAccountsOptions /// The commitment level to query at. public Commitment? Commitment { get; init; } - /// Filters every returned account must satisfy (memcmp / data size); none are applied when null. + /// Filters every returned account must satisfy (memcmp, data size, or token-account state); none are applied when null. public IReadOnlyList? Filters { get; init; } /// Return only this slice of each account's data; the whole account when null. @@ -16,4 +16,14 @@ public sealed record GetProgramAccountsOptions /// The minimum slot the request can be evaluated at. public ulong? MinContextSlot { get; init; } + + /// + /// Requests the upstream { context, value } response shape when true. Use + /// to retain that context; the + /// list-only method returns its value component. + /// + public bool? WithContext { get; init; } + + /// Whether the node sorts accounts by public key; use the node default when null. + public bool? SortResults { get; init; } } diff --git a/src/SolSharp.Rpc/Models/AccountInfo.cs b/src/SolSharp.Rpc/Models/AccountInfo.cs index 492abd3..6bbe536 100644 --- a/src/SolSharp.Rpc/Models/AccountInfo.cs +++ b/src/SolSharp.Rpc/Models/AccountInfo.cs @@ -23,6 +23,11 @@ public sealed record AccountInfo /// public ulong RentEpoch { get; init; } + /// + /// The account's complete data length before any requested data slice was applied, when reported. + /// + public ulong? Space { get; init; } + /// The account's raw data, decoded from the node's base64 encoding. public byte[] Data { get; init; } = []; } diff --git a/src/SolSharp.Rpc/Models/AccountInfoJsonConverter.cs b/src/SolSharp.Rpc/Models/AccountInfoJsonConverter.cs index 4bad8e2..ea86dbf 100644 --- a/src/SolSharp.Rpc/Models/AccountInfoJsonConverter.cs +++ b/src/SolSharp.Rpc/Models/AccountInfoJsonConverter.cs @@ -15,10 +15,7 @@ public override AccountInfo Read(ref Utf8JsonReader reader, Type typeToConvert, using var document = JsonDocument.ParseValue(ref reader); var root = document.RootElement; - var data = root.GetProperty("data"); - var bytes = data.ValueKind == JsonValueKind.Array && data.GetArrayLength() > 0 - ? Convert.FromBase64String(data[0].GetString() ?? string.Empty) - : []; + var bytes = DecodeBase64Tuple(root.GetProperty("data")); return new AccountInfo { @@ -26,6 +23,7 @@ public override AccountInfo Read(ref Utf8JsonReader reader, Type typeToConvert, Owner = new PublicKey(root.GetProperty("owner").GetString()!), Executable = root.GetProperty("executable").GetBoolean(), RentEpoch = root.GetProperty("rentEpoch").GetUInt64(), + Space = ReadOptionalSpace(root), Data = bytes }; } @@ -37,6 +35,8 @@ public override void Write(Utf8JsonWriter writer, AccountInfo value, JsonSeriali writer.WriteString("owner", value.Owner.ToString()); writer.WriteBoolean("executable", value.Executable); writer.WriteNumber("rentEpoch", value.RentEpoch); + if (value.Space is { } space) + writer.WriteNumber("space", space); writer.WriteStartArray("data"); writer.WriteStringValue(Convert.ToBase64String(value.Data)); @@ -45,4 +45,34 @@ public override void Write(Utf8JsonWriter writer, AccountInfo value, JsonSeriali writer.WriteEndObject(); } + + internal static byte[] DecodeBase64Tuple(JsonElement data) + { + if (data.ValueKind != JsonValueKind.Array || data.GetArrayLength() != 2) + throw new JsonException("Expected account data as a two-element [data, encoding] array."); + if (data[0].ValueKind != JsonValueKind.String) + throw new JsonException("Expected account data as a string."); + if (data[1].ValueKind != JsonValueKind.String || data[1].GetString() != "base64") + throw new JsonException("Expected account data encoding base64."); + + try + { + return Convert.FromBase64String(data[0].GetString()!); + } + catch (FormatException exception) + { + throw new JsonException("Account data is not valid base64.", exception); + } + } + + internal static ulong? ReadOptionalSpace(JsonElement account) + { + if (!account.TryGetProperty("space", out var space) || space.ValueKind is JsonValueKind.Null) + return null; + + if (space.ValueKind is JsonValueKind.Number && space.TryGetUInt64(out var value)) + return value; + + throw new JsonException("Account space must be null or a u64 value."); + } } diff --git a/src/SolSharp.Rpc/Models/AddressLookupTable.cs b/src/SolSharp.Rpc/Models/AddressLookupTable.cs index 873241f..69f39cb 100644 --- a/src/SolSharp.Rpc/Models/AddressLookupTable.cs +++ b/src/SolSharp.Rpc/Models/AddressLookupTable.cs @@ -3,8 +3,24 @@ namespace SolSharp.Rpc.Models; +/// The lookup lifecycle state that can be established from ALT metadata and observation context. +public enum AddressLookupTableLifecycle +{ + /// No deactivation has been requested. + Activated, + + /// The table is in its SlotHashes cooldown and remains usable for address lookups. + Deactivating, + + /// + /// Deactivation was requested, but the account response does not include SlotHashes and therefore cannot + /// distinguish a table still cooling down from one that is fully deactivated. + /// + DeactivationStatusUnknown +} + /// -/// A decoded on-chain Address Lookup Table account: its metadata plus the addresses it stores. Feed +/// A decoded on-chain Address Lookup Table account: its metadata plus its stored and context-visible addresses. Feed /// into a v0 transaction (an AddressLookupTableAccount in SolSharp.Programs) /// to load those accounts without listing them in the message. /// @@ -12,49 +28,145 @@ namespace SolSharp.Rpc.Models; public sealed record AddressLookupTable { private const int MetaSize = 56; + private const int MaxAddresses = 256; + private const ulong SlotHashesCapacity = 512; - /// The slot the table was deactivated at, or while it is still active. + /// The slot at which deactivation began, or when it has not begun. public required ulong DeactivationSlot { get; init; } /// The most recent slot in which the table was extended. public required ulong LastExtendedSlot { get; init; } + /// The first address index appended during . + public byte LastExtendedSlotStartIndex { get; init; } + /// The authority allowed to extend or close the table, or null if it has been frozen. public required PublicKey? Authority { get; init; } - /// The addresses the table stores, in index order. + /// The serialized metadata padding retained from the upstream layout. + public ushort Padding { get; init; } + + /// The RPC context slot used to determine address visibility, when the table came from the client. + public ulong? ContextSlot { get; init; } + + /// The complete serialized address list, including addresses not usable during their extension slot. + public IReadOnlyList StoredAddresses { get; init; } = []; + + /// + /// The addresses usable at , in index order. Addresses appended in the table's + /// extension slot are intentionally excluded, matching Agave transaction lookup semantics. + /// public required IReadOnlyList Addresses { get; init; } - /// True while the table is active (not deactivated) and so usable in new transactions. + /// + /// Whether deactivation has not begun. A false value means deactivation was requested, not that the + /// table is already unusable; inspect and for that distinction. + /// public bool IsActive => DeactivationSlot == ulong.MaxValue; + /// + /// The lifecycle state that can be established without SlotHashes. A finite old deactivation slot becomes + /// once usability cannot be proven. + /// + public AddressLookupTableLifecycle Lifecycle => GetLifecycleWithoutSlotHashes(); + + /// + /// Whether the table is known to be usable at . null means exact usability + /// requires the SlotHashes sysvar; it never guesses that a cooling-down table is inactive. + /// + public bool? IsUsable => Lifecycle switch + { + AddressLookupTableLifecycle.Activated or AddressLookupTableLifecycle.Deactivating => true, + _ => null + }; + /// Decodes a lookup table from its raw account data (the bytes getAccountInfo returns). /// The account's raw data. /// The decoded table, or null if the data is not an initialized lookup table. - public static AddressLookupTable? Decode(ReadOnlySpan data) + public static AddressLookupTable? Decode(ReadOnlySpan data) => Decode(data, contextSlot: null); + + /// + /// Decodes a lookup table and exposes only addresses active at the supplied RPC context slot. + /// + /// The account's raw data. + /// The bank slot whose account state was returned. + /// The decoded table, or null if the data or metadata bounds are invalid. + public static AddressLookupTable? Decode(ReadOnlySpan data, ulong contextSlot) => Decode(data, (ulong?)contextSlot); + + private static AddressLookupTable? Decode(ReadOnlySpan data, ulong? contextSlot) { // Layout: u32 discriminant (1 = LookupTable), u64 deactivation slot, u64 last-extended slot, u8 start // index, Option authority (1-byte flag + 32-byte key), u16 padding = 56 bytes, then a tightly // packed array of 32-byte addresses. - if (data.Length < MetaSize || BinaryPrimitives.ReadUInt32LittleEndian(data) != 1) + if (data.Length < MetaSize + || (data.Length - MetaSize) % PublicKey.Length != 0 + || (data.Length - MetaSize) / PublicKey.Length > MaxAddresses + || BinaryPrimitives.ReadUInt32LittleEndian(data) != 1) return null; var deactivationSlot = BinaryPrimitives.ReadUInt64LittleEndian(data[4..]); var lastExtendedSlot = BinaryPrimitives.ReadUInt64LittleEndian(data[12..]); - PublicKey? authority = data[21] != 0 ? new PublicKey(data.Slice(22, PublicKey.Length)) : null; + var lastExtendedSlotStartIndex = data[20]; + PublicKey? authority; + ushort padding; + switch (data[21]) + { + case 0: + authority = null; + padding = BinaryPrimitives.ReadUInt16LittleEndian(data[22..]); + break; + case 1: + authority = new PublicKey(data.Slice(22, PublicKey.Length)); + padding = BinaryPrimitives.ReadUInt16LittleEndian(data[54..]); + break; + default: + return null; + } var addressBytes = data[MetaSize..]; var count = addressBytes.Length / PublicKey.Length; + if (lastExtendedSlotStartIndex > count) + return null; + var addresses = new PublicKey[count]; for (var i = 0; i < count; i++) addresses[i] = new PublicKey(addressBytes.Slice(i * PublicKey.Length, PublicKey.Length)); + IReadOnlyList activeAddresses = addresses; + if (contextSlot is { } observedSlot && observedSlot <= lastExtendedSlot) + { + var activePrefix = new PublicKey[lastExtendedSlotStartIndex]; + Array.Copy(addresses, activePrefix, activePrefix.Length); + activeAddresses = activePrefix; + } + return new AddressLookupTable { DeactivationSlot = deactivationSlot, LastExtendedSlot = lastExtendedSlot, + LastExtendedSlotStartIndex = lastExtendedSlotStartIndex, Authority = authority, - Addresses = addresses + Padding = padding, + ContextSlot = contextSlot, + StoredAddresses = addresses, + Addresses = activeAddresses }; } + + private AddressLookupTableLifecycle GetLifecycleWithoutSlotHashes() + { + if (DeactivationSlot == ulong.MaxValue) + return AddressLookupTableLifecycle.Activated; + + if (ContextSlot is not { } contextSlot) + return AddressLookupTableLifecycle.DeactivationStatusUnknown; + + if (contextSlot == DeactivationSlot || + (contextSlot > DeactivationSlot && contextSlot - DeactivationSlot <= SlotHashesCapacity)) + { + return AddressLookupTableLifecycle.Deactivating; + } + + return AddressLookupTableLifecycle.DeactivationStatusUnknown; + } } diff --git a/src/SolSharp.Rpc/Models/AgGenesisCertificate.cs b/src/SolSharp.Rpc/Models/AgGenesisCertificate.cs new file mode 100644 index 0000000..76b0f53 --- /dev/null +++ b/src/SolSharp.Rpc/Models/AgGenesisCertificate.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SolSharp.Rpc.Models; + +/// +/// The Alpenglow genesis block certificate returned by getAgGenesisCert when the cluster has +/// switched to Alpenglow consensus. +/// +public sealed record AgGenesisCertificate +{ + private AgGenesisBlock? _block; + private AgGenesisCertificateSignature? _signature; + + /// The block certified as the Alpenglow genesis block. + [JsonPropertyName("block")] + public required AgGenesisBlock Block + { + get => _block ?? throw new InvalidOperationException("The Alpenglow genesis block has not been initialized."); + init => _block = value ?? throw new JsonException("An Alpenglow genesis certificate must carry a block."); + } + + /// The aggregate BLS signature and validator-participation bitmap. + [JsonPropertyName("signature")] + public required AgGenesisCertificateSignature Signature + { + get => _signature ?? throw new InvalidOperationException("The Alpenglow genesis signature has not been initialized."); + init => _signature = value ?? throw new JsonException("An Alpenglow genesis certificate must carry a signature."); + } +} + +/// The block identified by an . +public sealed record AgGenesisBlock +{ + private IReadOnlyList? _blockId; + + /// The block's slot. + [JsonPropertyName("slot")] + public required ulong Slot { get; init; } + + /// The raw 32-byte block identifier. + [JsonPropertyName("block_id")] + public required IReadOnlyList BlockId + { + get => _blockId ?? throw new InvalidOperationException("The Alpenglow block identifier has not been initialized."); + init + { + if (value is null) + throw new JsonException("An Alpenglow block identifier cannot be null."); + if (value.Count != 32) + throw new JsonException("An Alpenglow block identifier must contain exactly 32 bytes."); + + _blockId = Array.AsReadOnly(value.ToArray()); + } + } +} + +/// The signature carried by an . +public sealed record AgGenesisCertificateSignature +{ + private IReadOnlyList? _signature; + private IReadOnlyList? _bitmap; + + /// The raw 192-byte aggregate BLS signature in affine-point representation. + [JsonPropertyName("signature")] + public required IReadOnlyList Signature + { + get => _signature ?? throw new InvalidOperationException("The Alpenglow aggregate signature has not been initialized."); + init + { + if (value is null) + throw new JsonException("An Alpenglow aggregate signature cannot be null."); + if (value.Count != 192) + throw new JsonException("An Alpenglow aggregate signature must contain exactly 192 bytes."); + + _signature = Array.AsReadOnly(value.ToArray()); + } + } + + /// + /// A bitmap whose set bits identify the validator ranks included in the aggregate signature; + /// the pinned certificate format supports at most 4,096 validators (512 bytes). + /// + [JsonPropertyName("bitmap")] + public required IReadOnlyList Bitmap + { + get => _bitmap ?? throw new InvalidOperationException("The Alpenglow validator bitmap has not been initialized."); + init + { + if (value is null) + throw new JsonException("An Alpenglow validator bitmap cannot be null."); + if (value.Count > 512) + throw new JsonException("An Alpenglow validator bitmap cannot exceed 512 bytes."); + _bitmap = Array.AsReadOnly(value.ToArray()); + } + } +} diff --git a/src/SolSharp.Rpc/Models/Base64TupleJsonConverter.cs b/src/SolSharp.Rpc/Models/Base64TupleJsonConverter.cs index 83b8428..1354751 100644 --- a/src/SolSharp.Rpc/Models/Base64TupleJsonConverter.cs +++ b/src/SolSharp.Rpc/Models/Base64TupleJsonConverter.cs @@ -17,11 +17,11 @@ internal sealed class Base64TupleJsonConverter : JsonConverter using var document = JsonDocument.ParseValue(ref reader); var root = document.RootElement; if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength() != 2) - throw new JsonException("Expected base64 transaction data as a two-element array."); + throw new JsonException("Expected base64 binary data as a two-element array."); if (root[0].ValueKind != JsonValueKind.String) - throw new JsonException("Expected base64 transaction data as a string."); + throw new JsonException("Expected base64 binary data as a string."); if (root[1].ValueKind != JsonValueKind.String || root[1].GetString() != "base64") - throw new JsonException("Expected transaction encoding base64."); + throw new JsonException("Expected binary data encoding base64."); try { @@ -29,7 +29,7 @@ internal sealed class Base64TupleJsonConverter : JsonConverter } catch (FormatException exception) { - throw new JsonException("Transaction data is not valid base64.", exception); + throw new JsonException("Binary data is not valid base64.", exception); } } diff --git a/src/SolSharp.Rpc/Models/Block.cs b/src/SolSharp.Rpc/Models/Block.cs index f1ae19d..2d83387 100644 --- a/src/SolSharp.Rpc/Models/Block.cs +++ b/src/SolSharp.Rpc/Models/Block.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace SolSharp.Rpc.Models; @@ -9,27 +10,59 @@ namespace SolSharp.Rpc.Models; /// getBlock public sealed record Block { + private string? _blockhash; + private string? _previousBlockhash; + private IReadOnlyList? _signatures; + /// The block's blockhash (base58). [JsonPropertyName("blockhash")] - public string Blockhash { get; init; } = string.Empty; + [JsonRequired] + public string Blockhash + { + get => _blockhash ?? throw new InvalidOperationException("The blockhash has not been initialized."); + init => _blockhash = value ?? throw new JsonException("A block must carry its blockhash."); + } /// The blockhash of this block's parent (base58). [JsonPropertyName("previousBlockhash")] - public string PreviousBlockhash { get; init; } = string.Empty; + [JsonRequired] + public string PreviousBlockhash + { + get => _previousBlockhash ?? throw new InvalidOperationException("The previous blockhash has not been initialized."); + init => _previousBlockhash = value ?? throw new JsonException("A block must carry its previous blockhash."); + } /// The slot of this block's parent. [JsonPropertyName("parentSlot")] + [JsonRequired] public ulong ParentSlot { get; init; } /// The block's height, if the node reported it. [JsonPropertyName("blockHeight")] + [JsonRequired] public ulong? BlockHeight { get; init; } /// The block's production time as Unix seconds, or null if not available. [JsonPropertyName("blockTime")] + [JsonRequired] public long? BlockTime { get; init; } + /// The number of partitions used for epoch rewards in this block, when applicable. + [JsonPropertyName("numRewardPartitions")] + public ulong? NumRewardPartitions { get; init; } + /// The signatures of the transactions in the block, in order. [JsonPropertyName("signatures")] - public IReadOnlyList? Signatures { get; init; } + [JsonRequired] + public IReadOnlyList Signatures + { + get => _signatures ?? throw new InvalidOperationException("The block signatures have not been initialized."); + init + { + if (value is null || value.Any(static signature => signature is null)) + throw new JsonException("A signatures-only block must carry only non-null signatures."); + + _signatures = value; + } + } } diff --git a/src/SolSharp.Rpc/Models/BlockCommitment.cs b/src/SolSharp.Rpc/Models/BlockCommitment.cs index 150a164..8938d35 100644 --- a/src/SolSharp.Rpc/Models/BlockCommitment.cs +++ b/src/SolSharp.Rpc/Models/BlockCommitment.cs @@ -15,5 +15,6 @@ public sealed record BlockCommitment /// The total active stake in lamports for the current epoch. [JsonPropertyName("totalStake")] + [JsonRequired] public ulong TotalStake { get; init; } } diff --git a/src/SolSharp.Rpc/Models/BlockProduction.cs b/src/SolSharp.Rpc/Models/BlockProduction.cs index efe3e6a..d3ac204 100644 --- a/src/SolSharp.Rpc/Models/BlockProduction.cs +++ b/src/SolSharp.Rpc/Models/BlockProduction.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace SolSharp.Rpc.Models; @@ -6,17 +7,73 @@ namespace SolSharp.Rpc.Models; /// getBlockProduction public sealed record BlockProduction { - /// - /// Production per validator identity (base58): a two-element list of the number of leader slots - /// followed by the number of blocks actually produced. - /// + private IReadOnlyDictionary? _byIdentity; + private BlockProductionRange? _range; + + /// Production counts keyed by validator identity (base58). [JsonPropertyName("byIdentity")] - public IReadOnlyDictionary> ByIdentity { get; init; } = - new Dictionary>(); + [JsonRequired] + public IReadOnlyDictionary ByIdentity + { + get => _byIdentity ?? throw new InvalidOperationException("Block-production identities have not been initialized."); + init => _byIdentity = value ?? throw new JsonException("Block production must carry its identity counts."); + } /// The slot range the production information covers. [JsonPropertyName("range")] - public BlockProductionRange Range { get; init; } = new(); + [JsonRequired] + public BlockProductionRange Range + { + get => _range ?? throw new InvalidOperationException("The block-production range has not been initialized."); + init => _range = value ?? throw new JsonException("Block production must carry its slot range."); + } +} + +/// The exact [leaderSlots, blocksProduced] tuple for one validator identity. +[JsonConverter(typeof(BlockProductionCountsJsonConverter))] +public readonly record struct BlockProductionCounts +{ + /// Creates one exact block-production count tuple. + /// The number of slots assigned to the validator. + /// The number of assigned slots in which it produced a block. + public BlockProductionCounts(ulong leaderSlots, ulong blocksProduced) + { + LeaderSlots = leaderSlots; + BlocksProduced = blocksProduced; + } + + /// The number of slots assigned to the validator. + public ulong LeaderSlots { get; } + + /// The number of assigned slots in which the validator produced a block. + public ulong BlocksProduced { get; } +} + +/// Reads and writes the exact two-element block-production count tuple used by Agave. +public sealed class BlockProductionCountsJsonConverter : JsonConverter +{ + /// + public override BlockProductionCounts Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray || + !reader.Read() || reader.TokenType != JsonTokenType.Number || !reader.TryGetUInt64(out var leaderSlots) || + !reader.Read() || reader.TokenType != JsonTokenType.Number || !reader.TryGetUInt64(out var blocksProduced) || + !reader.Read() || reader.TokenType != JsonTokenType.EndArray) + { + throw new JsonException("A block-production count must be exactly [leaderSlots, blocksProduced] as u64 values."); + } + + return new BlockProductionCounts(leaderSlots, blocksProduced); + } + + /// + public override void Write(Utf8JsonWriter writer, BlockProductionCounts value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + writer.WriteNumberValue(value.LeaderSlots); + writer.WriteNumberValue(value.BlocksProduced); + writer.WriteEndArray(); + } } /// The slot range covered by a result. @@ -24,9 +81,11 @@ public sealed record BlockProductionRange { /// The first slot of the range (inclusive). [JsonPropertyName("firstSlot")] + [JsonRequired] public ulong FirstSlot { get; init; } /// The last slot of the range (inclusive). [JsonPropertyName("lastSlot")] + [JsonRequired] public ulong LastSlot { get; init; } } diff --git a/src/SolSharp.Rpc/Models/ClusterNode.cs b/src/SolSharp.Rpc/Models/ClusterNode.cs index 1243783..e9d7fcf 100644 --- a/src/SolSharp.Rpc/Models/ClusterNode.cs +++ b/src/SolSharp.Rpc/Models/ClusterNode.cs @@ -9,29 +9,62 @@ public sealed record ClusterNode { /// The node's identity public key. [JsonPropertyName("pubkey")] + [JsonRequired] public PublicKey Pubkey { get; init; } /// The node's gossip network address (host:port), or null if unavailable. [JsonPropertyName("gossip")] public string? Gossip { get; init; } + /// The node's TVU (transaction validation unit) address, or null if unavailable. + [JsonPropertyName("tvu")] + public string? Tvu { get; init; } + /// The node's TPU (transaction processing unit) address, or null if unavailable. [JsonPropertyName("tpu")] public string? Tpu { get; init; } + /// The node's QUIC TPU address, or null if unavailable. + [JsonPropertyName("tpuQuic")] + public string? TpuQuic { get; init; } + + /// The node's UDP forwarding TPU address, or null if unavailable. + [JsonPropertyName("tpuForwards")] + public string? TpuForwards { get; init; } + + /// The node's QUIC forwarding TPU address, or null if unavailable. + [JsonPropertyName("tpuForwardsQuic")] + public string? TpuForwardsQuic { get; init; } + + /// The node's vote TPU address, or null if unavailable. + [JsonPropertyName("tpuVote")] + public string? TpuVote { get; init; } + + /// The node's repair-service address, or null if unavailable. + [JsonPropertyName("serveRepair")] + public string? ServeRepair { get; init; } + /// The node's JSON-RPC address, or null if it does not serve RPC. [JsonPropertyName("rpc")] public string? Rpc { get; init; } + /// The node's WebSocket PubSub address, or null if unavailable. + [JsonPropertyName("pubsub")] + public string? Pubsub { get; init; } + /// The node's software version, or null if unknown. [JsonPropertyName("version")] public string? Version { get; init; } + /// The validator client identifier, or null if the node did not report one. + [JsonPropertyName("clientId")] + public string? ClientId { get; init; } + /// The node's feature set id, or null if unknown. [JsonPropertyName("featureSet")] - public long? FeatureSet { get; init; } + public uint? FeatureSet { get; init; } /// The node's shred version, or null if unknown. [JsonPropertyName("shredVersion")] - public int? ShredVersion { get; init; } + public ushort? ShredVersion { get; init; } } diff --git a/src/SolSharp.Rpc/Models/EpochInfo.cs b/src/SolSharp.Rpc/Models/EpochInfo.cs index ba541c0..bc4302a 100644 --- a/src/SolSharp.Rpc/Models/EpochInfo.cs +++ b/src/SolSharp.Rpc/Models/EpochInfo.cs @@ -8,22 +8,27 @@ public sealed record EpochInfo { /// The current slot. [JsonPropertyName("absoluteSlot")] + [JsonRequired] public ulong AbsoluteSlot { get; init; } /// The current block height. [JsonPropertyName("blockHeight")] + [JsonRequired] public ulong BlockHeight { get; init; } /// The current epoch. [JsonPropertyName("epoch")] + [JsonRequired] public ulong Epoch { get; init; } /// The current slot's index relative to the start of the epoch. [JsonPropertyName("slotIndex")] + [JsonRequired] public ulong SlotIndex { get; init; } /// The number of slots in the current epoch. [JsonPropertyName("slotsInEpoch")] + [JsonRequired] public ulong SlotsInEpoch { get; init; } /// The total number of transactions processed without error since genesis, if the node reports it. diff --git a/src/SolSharp.Rpc/Models/EpochSchedule.cs b/src/SolSharp.Rpc/Models/EpochSchedule.cs index ef9ac8a..64b411a 100644 --- a/src/SolSharp.Rpc/Models/EpochSchedule.cs +++ b/src/SolSharp.Rpc/Models/EpochSchedule.cs @@ -8,21 +8,26 @@ public sealed record EpochSchedule { /// The maximum number of slots in each epoch. [JsonPropertyName("slotsPerEpoch")] + [JsonRequired] public ulong SlotsPerEpoch { get; init; } /// The number of slots before the start of an epoch at which its leader schedule is computed. [JsonPropertyName("leaderScheduleSlotOffset")] + [JsonRequired] public ulong LeaderScheduleSlotOffset { get; init; } /// Whether epochs start short and grow (the warmup period). [JsonPropertyName("warmup")] + [JsonRequired] public bool Warmup { get; init; } /// The first epoch of normal length (after warmup). [JsonPropertyName("firstNormalEpoch")] + [JsonRequired] public ulong FirstNormalEpoch { get; init; } /// The slot at which begins. [JsonPropertyName("firstNormalSlot")] + [JsonRequired] public ulong FirstNormalSlot { get; init; } } diff --git a/src/SolSharp.Rpc/Models/HighestSnapshotSlot.cs b/src/SolSharp.Rpc/Models/HighestSnapshotSlot.cs index efd4d80..08b0c38 100644 --- a/src/SolSharp.Rpc/Models/HighestSnapshotSlot.cs +++ b/src/SolSharp.Rpc/Models/HighestSnapshotSlot.cs @@ -8,6 +8,7 @@ public sealed record HighestSnapshotSlot { /// The highest slot the node has a full snapshot for. [JsonPropertyName("full")] + [JsonRequired] public ulong Full { get; init; } /// The highest slot with an incremental snapshot based on , if any. diff --git a/src/SolSharp.Rpc/Models/InflationGovernor.cs b/src/SolSharp.Rpc/Models/InflationGovernor.cs index b581aff..3062704 100644 --- a/src/SolSharp.Rpc/Models/InflationGovernor.cs +++ b/src/SolSharp.Rpc/Models/InflationGovernor.cs @@ -8,21 +8,26 @@ public sealed record InflationGovernor { /// The initial inflation percentage from time 0. [JsonPropertyName("initial")] + [JsonRequired] public double Initial { get; init; } /// The terminal inflation percentage. [JsonPropertyName("terminal")] + [JsonRequired] public double Terminal { get; init; } /// The rate per year at which inflation is lowered (until the terminal rate). [JsonPropertyName("taper")] + [JsonRequired] public double Taper { get; init; } /// The percentage of total inflation allocated to the foundation. [JsonPropertyName("foundation")] + [JsonRequired] public double Foundation { get; init; } /// The duration of the foundation pool inflation, in years. [JsonPropertyName("foundationTerm")] + [JsonRequired] public double FoundationTerm { get; init; } } diff --git a/src/SolSharp.Rpc/Models/InflationRate.cs b/src/SolSharp.Rpc/Models/InflationRate.cs index d38e789..ae9eec4 100644 --- a/src/SolSharp.Rpc/Models/InflationRate.cs +++ b/src/SolSharp.Rpc/Models/InflationRate.cs @@ -8,17 +8,21 @@ public sealed record InflationRate { /// The total inflation percentage. [JsonPropertyName("total")] + [JsonRequired] public double Total { get; init; } /// The portion of inflation allocated to validators. [JsonPropertyName("validator")] + [JsonRequired] public double Validator { get; init; } /// The portion of inflation allocated to the foundation. [JsonPropertyName("foundation")] + [JsonRequired] public double Foundation { get; init; } /// The epoch the values are valid for. [JsonPropertyName("epoch")] + [JsonRequired] public ulong Epoch { get; init; } } diff --git a/src/SolSharp.Rpc/Models/InflationReward.cs b/src/SolSharp.Rpc/Models/InflationReward.cs index ed72377..c7d876c 100644 --- a/src/SolSharp.Rpc/Models/InflationReward.cs +++ b/src/SolSharp.Rpc/Models/InflationReward.cs @@ -8,21 +8,29 @@ public sealed record InflationReward { /// The epoch the reward was paid for. [JsonPropertyName("epoch")] + [JsonRequired] public ulong Epoch { get; init; } /// The slot at which the reward was credited. [JsonPropertyName("effectiveSlot")] + [JsonRequired] public ulong EffectiveSlot { get; init; } /// The reward amount, in lamports. [JsonPropertyName("amount")] + [JsonRequired] public ulong Amount { get; init; } /// The account balance, in lamports, after the reward was applied. [JsonPropertyName("postBalance")] + [JsonRequired] public ulong PostBalance { get; init; } /// The vote account commission applied to this reward, when it is a voting reward; otherwise null. [JsonPropertyName("commission")] public byte? Commission { get; init; } + + /// The reward commission in basis points, when reported by nodes supporting SIMD-0291. + [JsonPropertyName("commissionBps")] + public ushort? CommissionBps { get; init; } } diff --git a/src/SolSharp.Rpc/Models/LargestAccount.cs b/src/SolSharp.Rpc/Models/LargestAccount.cs index d3914b3..41505c5 100644 --- a/src/SolSharp.Rpc/Models/LargestAccount.cs +++ b/src/SolSharp.Rpc/Models/LargestAccount.cs @@ -9,9 +9,11 @@ public sealed record LargestAccount { /// The account's address. [JsonPropertyName("address")] + [JsonRequired] public PublicKey Address { get; init; } /// The account's balance in lamports. [JsonPropertyName("lamports")] + [JsonRequired] public ulong Lamports { get; init; } } diff --git a/src/SolSharp.Rpc/Models/LatestBlockhash.cs b/src/SolSharp.Rpc/Models/LatestBlockhash.cs index f665a35..04cf11c 100644 --- a/src/SolSharp.Rpc/Models/LatestBlockhash.cs +++ b/src/SolSharp.Rpc/Models/LatestBlockhash.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace SolSharp.Rpc.Models; @@ -6,11 +7,19 @@ namespace SolSharp.Rpc.Models; /// getLatestBlockhash public sealed record LatestBlockhash { + private string? _blockhash; + /// The base58-encoded recent blockhash to set on a transaction. [JsonPropertyName("blockhash")] - public string Blockhash { get; init; } = string.Empty; + [JsonRequired] + public string Blockhash + { + get => _blockhash ?? throw new InvalidOperationException("The latest blockhash has not been initialized."); + init => _blockhash = value ?? throw new JsonException("A latest-blockhash result must carry a blockhash."); + } /// The last block height at which is still accepted. [JsonPropertyName("lastValidBlockHeight")] + [JsonRequired] public ulong LastValidBlockHeight { get; init; } } diff --git a/src/SolSharp.Rpc/Models/Mint.cs b/src/SolSharp.Rpc/Models/Mint.cs index 6ef800a..5e860e0 100644 --- a/src/SolSharp.Rpc/Models/Mint.cs +++ b/src/SolSharp.Rpc/Models/Mint.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using SolSharp.Core.Primitives; +using SolSharp.Rpc.Models.Token2022; namespace SolSharp.Rpc.Models; @@ -27,19 +28,24 @@ public sealed record Mint /// Decodes a mint from its raw account data (the bytes getAccountInfo returns). /// The account's raw data. - /// The decoded mint, or null if the data is too short to be a mint account. + /// The decoded mint, or null if the data is not a canonical Token or Token-2022 mint layout. public static Mint? Decode(ReadOnlySpan data) { - if (data.Length < Length) + if (data.Length != Length && TokenExtensionSet.DecodeMint(data) is null) + return null; + + if (!SplLayout.TryReadCOptionPublicKey(data, 0, out var mintAuthority) + || data[45] > 1 + || !SplLayout.TryReadCOptionPublicKey(data, 46, out var freezeAuthority)) return null; return new Mint { - MintAuthority = SplLayout.ReadCOptionPublicKey(data, 0), + MintAuthority = mintAuthority, Supply = BinaryPrimitives.ReadUInt64LittleEndian(data[36..]), Decimals = data[44], - IsInitialized = data[45] != 0, - FreezeAuthority = SplLayout.ReadCOptionPublicKey(data, 46) + IsInitialized = data[45] == 1, + FreezeAuthority = freezeAuthority }; } } diff --git a/src/SolSharp.Rpc/Models/NodeIdentity.cs b/src/SolSharp.Rpc/Models/NodeIdentity.cs index 1c9140b..564d3c9 100644 --- a/src/SolSharp.Rpc/Models/NodeIdentity.cs +++ b/src/SolSharp.Rpc/Models/NodeIdentity.cs @@ -10,5 +10,6 @@ namespace SolSharp.Rpc.Models; internal sealed record NodeIdentity { [JsonPropertyName("identity")] + [JsonRequired] public PublicKey Identity { get; init; } } diff --git a/src/SolSharp.Rpc/Models/NonceAccount.cs b/src/SolSharp.Rpc/Models/NonceAccount.cs index 1548cdf..6f60214 100644 --- a/src/SolSharp.Rpc/Models/NonceAccount.cs +++ b/src/SolSharp.Rpc/Models/NonceAccount.cs @@ -35,17 +35,18 @@ public sealed record NonceAccount /// public static NonceAccount? Decode(ReadOnlySpan data) { - if (data.Length < Length) + if (data.Length != Length) return null; // bincode enum tags: Versions (0 = Legacy, 1 = Current), then State (0 = Uninitialized, 1 = Initialized). + var version = BinaryPrimitives.ReadUInt32LittleEndian(data); var state = BinaryPrimitives.ReadUInt32LittleEndian(data[4..]); - if (state != 1) + if (version > 1 || state != 1) return null; return new NonceAccount { - Version = BinaryPrimitives.ReadUInt32LittleEndian(data), + Version = version, Authority = new PublicKey(data.Slice(8, PublicKey.Length)), Nonce = Base58.Encode(data.Slice(40, 32)), LamportsPerSignature = BinaryPrimitives.ReadUInt64LittleEndian(data[72..]) diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedAccountInfoJsonConverter.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedAccountInfoJsonConverter.cs index 2a3872c..71d3cc0 100644 --- a/src/SolSharp.Rpc/Models/Parsed/ParsedAccountInfoJsonConverter.cs +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedAccountInfoJsonConverter.cs @@ -25,22 +25,39 @@ public override ParsedAccountInfo Read(ref Utf8JsonReader reader, Type typeToCon if (data.ValueKind is JsonValueKind.Object) { - if (data.TryGetProperty("program", out var programElement)) - program = programElement.GetString(); + if (!data.TryGetProperty("program", out var programElement) || + programElement.ValueKind is not JsonValueKind.String) + { + throw new JsonException("Parsed account data must carry its string program."); + } - if (data.TryGetProperty("parsed", out var parsedElement) && parsedElement.ValueKind is not JsonValueKind.Null) - parsed = parsedElement.Deserialize(options.GetTypeInfo()); + if (!data.TryGetProperty("parsed", out var parsedElement)) + throw new JsonException("Parsed account data must carry its parsed value."); - if (data.TryGetProperty("space", out var dataSpace) && dataSpace.ValueKind is JsonValueKind.Number) - space = dataSpace.GetUInt64(); + if (!data.TryGetProperty("space", out var dataSpace) || + dataSpace.ValueKind is not JsonValueKind.Number || + !dataSpace.TryGetUInt64(out var parsedSpace)) + { + throw new JsonException("Parsed account data must carry its space as a u64 value."); + } + + program = programElement.GetString(); + parsed = parsedElement.ValueKind is JsonValueKind.Null + ? null + : parsedElement.Deserialize(options.GetTypeInfo()); + space = parsedSpace; + } + else if (data.ValueKind is JsonValueKind.Array) + { + rawData = AccountInfoJsonConverter.DecodeBase64Tuple(data); } - else if (data.ValueKind is JsonValueKind.Array && data.GetArrayLength() > 0) + else { - rawData = Convert.FromBase64String(data[0].GetString() ?? string.Empty); + throw new JsonException("Expected parsed account data as an object or a [data, encoding] array."); } - if (space is null && root.TryGetProperty("space", out var topSpace) && topSpace.ValueKind is JsonValueKind.Number) - space = topSpace.GetUInt64(); + var topLevelSpace = AccountInfoJsonConverter.ReadOptionalSpace(root); + space ??= topLevelSpace; return new ParsedAccountInfo { diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedAccountKey.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedAccountKey.cs index 41a6fad..b8038b8 100644 --- a/src/SolSharp.Rpc/Models/Parsed/ParsedAccountKey.cs +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedAccountKey.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; using SolSharp.Core.Primitives; @@ -7,16 +8,21 @@ namespace SolSharp.Rpc.Models.Parsed; /// Solana RPC JSON structures public sealed record ParsedAccountKey { + private string? _source; + /// The account address. [JsonPropertyName("pubkey")] + [JsonRequired] public PublicKey Pubkey { get; init; } /// Whether the account signed the transaction. [JsonPropertyName("signer")] + [JsonRequired] public bool Signer { get; init; } /// Whether the account is writable. [JsonPropertyName("writable")] + [JsonRequired] public bool Writable { get; init; } /// @@ -24,5 +30,16 @@ public sealed record ParsedAccountKey /// from an address lookup table; null if the node did not report it. /// [JsonPropertyName("source")] - public string? Source { get; init; } + [JsonRequired] + public string? Source + { + get => _source; + init + { + if (value is not null and not "transaction" and not "lookupTable") + throw new JsonException("A parsed account source must be transaction, lookupTable, or null."); + + _source = value; + } + } } diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedAddressTableLookup.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedAddressTableLookup.cs new file mode 100644 index 0000000..dee448b --- /dev/null +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedAddressTableLookup.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc.Models.Parsed; + +/// An address lookup-table reference embedded in a parsed versioned message. +public sealed record ParsedAddressTableLookup +{ + private IReadOnlyList? _writableIndexes; + private IReadOnlyList? _readonlyIndexes; + + /// The address lookup-table account. + [JsonPropertyName("accountKey")] + [JsonRequired] + public PublicKey AccountKey { get; init; } + + /// Indexes of writable addresses loaded from the table. + [JsonPropertyName("writableIndexes")] + [JsonRequired] + public IReadOnlyList WritableIndexes + { + get => _writableIndexes!; + init => _writableIndexes = value ?? throw new JsonException("An address-table lookup must carry writable indexes."); + } + + /// Indexes of read-only addresses loaded from the table. + [JsonPropertyName("readonlyIndexes")] + [JsonRequired] + public IReadOnlyList ReadonlyIndexes + { + get => _readonlyIndexes!; + init => _readonlyIndexes = value ?? throw new JsonException("An address-table lookup must carry read-only indexes."); + } +} diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedBlock.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedBlock.cs index 2d4c53b..541b36e 100644 --- a/src/SolSharp.Rpc/Models/Parsed/ParsedBlock.cs +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedBlock.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace SolSharp.Rpc.Models.Parsed; @@ -9,30 +10,63 @@ namespace SolSharp.Rpc.Models.Parsed; /// getBlock public sealed record ParsedBlock { + private string? _blockhash; + private string? _previousBlockhash; + private IReadOnlyList? _transactions; + /// The block's blockhash (base58). [JsonPropertyName("blockhash")] - public string Blockhash { get; init; } = string.Empty; + [JsonRequired] + public string Blockhash + { + get => _blockhash!; + init => _blockhash = value ?? throw new JsonException("A parsed block must carry a blockhash."); + } /// The blockhash of this block's parent (base58). [JsonPropertyName("previousBlockhash")] - public string PreviousBlockhash { get; init; } = string.Empty; + [JsonRequired] + public string PreviousBlockhash + { + get => _previousBlockhash!; + init => _previousBlockhash = value ?? throw new JsonException("A parsed block must carry a previous blockhash."); + } /// The slot of this block's parent. [JsonPropertyName("parentSlot")] + [JsonRequired] public ulong ParentSlot { get; init; } /// The block's height, if the node reported it. [JsonPropertyName("blockHeight")] + [JsonRequired] public ulong? BlockHeight { get; init; } /// The block's production time as Unix seconds, or null if not available. [JsonPropertyName("blockTime")] + [JsonRequired] public long? BlockTime { get; init; } + /// The number of partitions used for epoch rewards in this block, when applicable. + [JsonPropertyName("numRewardPartitions")] + public ulong? NumRewardPartitions { get; init; } + /// /// The block's transactions, decoded. and - /// are filled in from the block by GetParsedBlockAsync. + /// are filled in from the block, and + /// from its ledger order, by GetParsedBlockAsync. /// [JsonPropertyName("transactions")] - public IReadOnlyList Transactions { get; init; } = []; + [JsonRequired] + public IReadOnlyList Transactions + { + get => _transactions!; + init + { + if (value is null || value.Any(static transaction => transaction is null)) + throw new JsonException("A parsed block must carry only non-null transactions."); + + _transactions = value; + } + } } diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedInnerInstructions.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedInnerInstructions.cs index c318f15..aea5abf 100644 --- a/src/SolSharp.Rpc/Models/Parsed/ParsedInnerInstructions.cs +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedInnerInstructions.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace SolSharp.Rpc.Models.Parsed; @@ -6,11 +7,25 @@ namespace SolSharp.Rpc.Models.Parsed; /// Solana RPC JSON structures public sealed record ParsedInnerInstructions { + private IReadOnlyList? _instructions; + /// The index of the top-level instruction these inner instructions were invoked from. [JsonPropertyName("index")] - public int Index { get; init; } + [JsonRequired] + public byte Index { get; init; } /// The inner instructions, in invocation order. [JsonPropertyName("instructions")] - public IReadOnlyList Instructions { get; init; } = []; + [JsonRequired] + public IReadOnlyList Instructions + { + get => _instructions!; + init + { + if (value is null || value.Any(static instruction => instruction is null)) + throw new JsonException("Parsed inner instructions must carry only non-null instructions."); + + _instructions = value; + } + } } diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedInstruction.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedInstruction.cs index f6554bb..f6ab299 100644 --- a/src/SolSharp.Rpc/Models/Parsed/ParsedInstruction.cs +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedInstruction.cs @@ -9,6 +9,7 @@ namespace SolSharp.Rpc.Models.Parsed; /// forms always carry , so no information is lost either way. /// /// Solana RPC JSON structures +[JsonConverter(typeof(ParsedInstructionJsonConverter))] public sealed record ParsedInstruction { /// The program that runs the instruction. @@ -19,7 +20,11 @@ public sealed record ParsedInstruction [JsonPropertyName("program")] public string? Program { get; init; } - /// The node's parsed view of the instruction, or null when the program was not recognized. + /// + /// The node's parsed view of the instruction, or null when the program was not recognized. When the + /// wire's parsed value is JSON null, this remains non-null and carries that value in + /// so the parsed branch is not confused with a partially decoded one. + /// [JsonPropertyName("parsed")] public ParsedInstructionInfo? Parsed { get; init; } @@ -33,5 +38,5 @@ public sealed record ParsedInstruction /// The CPI stack height at which the instruction ran, if the node reported it. [JsonPropertyName("stackHeight")] - public int? StackHeight { get; init; } + public uint? StackHeight { get; init; } } diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedInstructionInfoJsonConverter.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedInstructionInfoJsonConverter.cs index 7ab1b7c..d7e95d2 100644 --- a/src/SolSharp.Rpc/Models/Parsed/ParsedInstructionInfoJsonConverter.cs +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedInstructionInfoJsonConverter.cs @@ -16,13 +16,19 @@ public override ParsedInstructionInfo Read(ref Utf8JsonReader reader, Type typeT using var document = JsonDocument.ParseValue(ref reader); var root = document.RootElement; - if (root.ValueKind is not JsonValueKind.Object) + if (root.ValueKind is not JsonValueKind.Object || + !root.TryGetProperty("type", out var type) || + type.ValueKind is not JsonValueKind.String || + !root.TryGetProperty("info", out var info) || + root.EnumerateObject().Count() != 2) + { return new ParsedInstructionInfo { Info = root.Clone() }; + } return new ParsedInstructionInfo { - Type = root.TryGetProperty("type", out var type) ? type.GetString() ?? string.Empty : string.Empty, - Info = root.TryGetProperty("info", out var info) ? info.Clone() : root.Clone() + Type = type.GetString()!, + Info = info.Clone() }; } diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedInstructionJsonConverter.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedInstructionJsonConverter.cs new file mode 100644 index 0000000..6e81634 --- /dev/null +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedInstructionJsonConverter.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SolSharp.Core.Primitives; +using SolSharp.Rpc.Protocol; + +namespace SolSharp.Rpc.Models.Parsed; + +/// Reads the two exact, mutually exclusive shapes of an Agave parsed instruction. +internal sealed class ParsedInstructionJsonConverter : JsonConverter +{ + public override ParsedInstruction Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + if (root.ValueKind is not JsonValueKind.Object) + throw new JsonException("A parsed instruction must be an object."); + + var hasProgram = root.TryGetProperty("program", out var program); + var hasParsed = root.TryGetProperty("parsed", out var parsed); + var hasAccounts = root.TryGetProperty("accounts", out var accounts); + var hasData = root.TryGetProperty("data", out var data); + + var programId = ReadProgramId(root, options); + var stackHeight = ReadStackHeight(root); + + if (hasProgram || hasParsed) + { + if (!hasProgram || !hasParsed || hasAccounts || hasData || + program.ValueKind is not JsonValueKind.String) + { + throw new JsonException("A parsed instruction must carry exactly program, programId, parsed, and stackHeight."); + } + + var parsedInfo = parsed.ValueKind is JsonValueKind.Null + ? new ParsedInstructionInfo { Info = parsed.Clone() } + : parsed.Deserialize(options.GetTypeInfo()) + ?? throw new JsonException("A parsed instruction must carry a parsed JSON value."); + + return new ParsedInstruction + { + Program = program.GetString(), + ProgramId = programId, + Parsed = parsedInfo, + StackHeight = stackHeight + }; + } + + if (!hasAccounts || !hasData || accounts.ValueKind is not JsonValueKind.Array || + data.ValueKind is not JsonValueKind.String) + { + throw new JsonException("A partially decoded instruction must carry programId, accounts, data, and stackHeight."); + } + + var parsedAccounts = accounts.Deserialize(options.GetTypeInfo>()) + ?? throw new JsonException("A partially decoded instruction must carry a non-null accounts array."); + + return new ParsedInstruction + { + ProgramId = programId, + Accounts = parsedAccounts, + Data = data.GetString(), + StackHeight = stackHeight + }; + } + + public override void Write(Utf8JsonWriter writer, ParsedInstruction value, JsonSerializerOptions options) + => throw new NotSupportedException("ParsedInstruction is decoded from node responses and is not serialized."); + + private static PublicKey ReadProgramId(JsonElement root, JsonSerializerOptions options) + { + if (!root.TryGetProperty("programId", out var programId) || programId.ValueKind is not JsonValueKind.String) + throw new JsonException("A parsed instruction must carry a non-null programId."); + + return programId.Deserialize(options.GetTypeInfo()); + } + + private static uint? ReadStackHeight(JsonElement root) + { + if (!root.TryGetProperty("stackHeight", out var stackHeight)) + throw new JsonException("A parsed instruction must carry a stackHeight member."); + + if (stackHeight.ValueKind is JsonValueKind.Null) + return null; + + if (stackHeight.ValueKind is JsonValueKind.Number && stackHeight.TryGetUInt32(out var value)) + return value; + + throw new JsonException("A parsed instruction stackHeight must be an unsigned 32-bit integer or null."); + } +} diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedMessage.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedMessage.cs index c101db0..478af7d 100644 --- a/src/SolSharp.Rpc/Models/Parsed/ParsedMessage.cs +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedMessage.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace SolSharp.Rpc.Models.Parsed; @@ -6,15 +7,61 @@ namespace SolSharp.Rpc.Models.Parsed; /// Solana RPC JSON structures public sealed record ParsedMessage { + private IReadOnlyList? _accountKeys; + private IReadOnlyList? _instructions; + private string? _recentBlockhash; + private IReadOnlyList? _addressTableLookups; + /// The accounts the transaction references, in index order, each with its role flags. [JsonPropertyName("accountKeys")] - public IReadOnlyList AccountKeys { get; init; } = []; + [JsonRequired] + public IReadOnlyList AccountKeys + { + get => _accountKeys!; + init => _accountKeys = RequireNonNullEntries(value, "account keys"); + } /// The top-level instructions, in execution order. [JsonPropertyName("instructions")] - public IReadOnlyList Instructions { get; init; } = []; + [JsonRequired] + public IReadOnlyList Instructions + { + get => _instructions!; + init => _instructions = RequireNonNullEntries(value, "instructions"); + } /// The recent blockhash the transaction was built against (base58). [JsonPropertyName("recentBlockhash")] - public string RecentBlockhash { get; init; } = string.Empty; + [JsonRequired] + public string RecentBlockhash + { + get => _recentBlockhash!; + init => _recentBlockhash = value ?? throw new JsonException("A parsed message must carry a recent blockhash."); + } + + /// The address lookup-table references of a versioned message; absent for legacy messages. + [JsonPropertyName("addressTableLookups")] + public IReadOnlyList? AddressTableLookups + { + get => _addressTableLookups; + init => _addressTableLookups = value is null + ? null + : RequireNonNullEntries(value, "address-table lookups"); + } + + /// + /// The message-level execution configuration for a version-1 transaction; absent for legacy and v0 + /// messages. + /// + [JsonPropertyName("transactionConfig")] + public ParsedTransactionConfig? TransactionConfig { get; init; } + + private static IReadOnlyList RequireNonNullEntries(IReadOnlyList? values, string name) + where T : class + { + if (values is null || values.Any(static value => value is null)) + throw new JsonException($"A parsed message must carry only non-null {name}."); + + return values; + } } diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedProgramAccount.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedProgramAccount.cs new file mode 100644 index 0000000..18a1d7b --- /dev/null +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedProgramAccount.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc.Models.Parsed; + +/// A program-owned account decoded with jsonParsed encoding. +/// programSubscribe +public sealed record ParsedProgramAccount +{ + private ParsedAccountInfo? _account; + + /// The account address. + [JsonPropertyName("pubkey")] + public required PublicKey PublicKey { get; init; } + + /// The node-decoded account state. + [JsonPropertyName("account")] + public required ParsedAccountInfo Account + { + get => _account!; + init => _account = value ?? throw new JsonException("A parsed keyed account must carry a non-null account."); + } +} diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedTransaction.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedTransaction.cs index 16576f5..361a928 100644 --- a/src/SolSharp.Rpc/Models/Parsed/ParsedTransaction.cs +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedTransaction.cs @@ -27,4 +27,13 @@ public sealed record ParsedTransaction /// The block production time as Unix seconds, when known; otherwise null. public long? BlockTime { get; init; } + + /// + /// The transaction's index within its block. Read from getTransaction when reported and + /// derived from ledger order by GetParsedBlockAsync; null in streamed block notifications. + /// + public uint? TransactionIndex { get; init; } + + /// The wire transaction version, or null when the node omitted it. + public RpcTransactionVersion? Version { get; init; } } diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedTransactionConfig.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedTransactionConfig.cs new file mode 100644 index 0000000..1897a09 --- /dev/null +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedTransactionConfig.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace SolSharp.Rpc.Models.Parsed; + +/// +/// The transaction-level execution configuration embedded in a version-1 message. Every value is optional +/// because the V1 wire mask controls which settings are present. +/// +/// +/// Agave's UiTransactionConfig JSON contract. +/// +public sealed record ParsedTransactionConfig +{ + /// The total priority fee, in lamports; distinct from the legacy micro-lamports-per-CU price. + [JsonPropertyName("priorityFee")] + [JsonRequired] + public ulong? PriorityFee { get; init; } + + /// The requested compute-unit limit. + [JsonPropertyName("computeUnitLimit")] + [JsonRequired] + public uint? ComputeUnitLimit { get; init; } + + /// The requested loaded-account-data size limit, in bytes. + [JsonPropertyName("loadedAccountsDataSizeLimit")] + [JsonRequired] + public uint? LoadedAccountsDataSizeLimit { get; init; } + + /// The requested program heap size, in bytes. + [JsonPropertyName("heapSize")] + [JsonRequired] + public uint? HeapSize { get; init; } +} diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedTransactionJsonConverter.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedTransactionJsonConverter.cs index 2c3c4a8..3e6b527 100644 --- a/src/SolSharp.Rpc/Models/Parsed/ParsedTransactionJsonConverter.cs +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedTransactionJsonConverter.cs @@ -15,22 +15,90 @@ public override ParsedTransaction Read(ref Utf8JsonReader reader, Type typeToCon { using var document = JsonDocument.ParseValue(ref reader); var root = document.RootElement; - var transaction = root.GetProperty("transaction"); + if (root.ValueKind is not JsonValueKind.Object || + !root.TryGetProperty("transaction", out var transaction) || + transaction.ValueKind is not JsonValueKind.Object) + { + throw new JsonException("A parsed transaction must carry a non-null transaction object."); + } + + if (!transaction.TryGetProperty("signatures", out var signatures) || + signatures.ValueKind is not JsonValueKind.Array) + { + throw new JsonException("A parsed transaction must carry a non-null signatures array."); + } + + var parsedSignatures = signatures.Deserialize(options.GetTypeInfo>()); + if (parsedSignatures is null || parsedSignatures.Any(static signature => signature is null)) + throw new JsonException("A parsed transaction must carry only non-null signatures."); + + if (!transaction.TryGetProperty("message", out var message) || + message.ValueKind is not JsonValueKind.Object) + { + throw new JsonException("A parsed transaction must carry a non-null message object."); + } + + var parsedMessage = message.Deserialize(options.GetTypeInfo()) + ?? throw new JsonException("A parsed transaction must carry a non-null message object."); + + if (!root.TryGetProperty("meta", out var meta)) + throw new JsonException("A parsed transaction must carry a metadata member."); + + ParsedTransactionMeta? parsedMeta = null; + if (meta.ValueKind is not JsonValueKind.Null) + { + if (meta.ValueKind is not JsonValueKind.Object) + throw new JsonException("Parsed transaction metadata must be an object or null."); + + parsedMeta = meta.Deserialize(options.GetTypeInfo()) + ?? throw new JsonException("Parsed transaction metadata must be an object or null."); + } + + var hasSlot = root.TryGetProperty("slot", out var slot); + var hasBlockTime = root.TryGetProperty("blockTime", out var blockTime); + if (hasSlot != hasBlockTime) + throw new JsonException("A confirmed parsed transaction must carry both slot and block time."); + + ulong? parsedSlot = null; + if (hasSlot) + { + if (slot.ValueKind is not JsonValueKind.Number || !slot.TryGetUInt64(out var slotValue)) + throw new JsonException("A parsed transaction slot must be an unsigned 64-bit integer."); + + parsedSlot = slotValue; + } + + long? parsedBlockTime = null; + if (hasBlockTime && blockTime.ValueKind is not JsonValueKind.Null) + { + if (blockTime.ValueKind is not JsonValueKind.Number || !blockTime.TryGetInt64(out var blockTimeValue)) + throw new JsonException("A parsed transaction block time must be a signed 64-bit integer or null."); + + parsedBlockTime = blockTimeValue; + } + + uint? parsedTransactionIndex = null; + if (root.TryGetProperty("transactionIndex", out var transactionIndex)) + { + if (transactionIndex.ValueKind is not JsonValueKind.Number || + !transactionIndex.TryGetUInt32(out var transactionIndexValue)) + { + throw new JsonException("A parsed transaction index must be an unsigned 32-bit integer."); + } + + parsedTransactionIndex = transactionIndexValue; + } return new ParsedTransaction { - Signatures = transaction.TryGetProperty("signatures", out var signatures) - ? signatures.Deserialize(options.GetTypeInfo>()) ?? [] - : [], - Message = transaction.GetProperty("message").Deserialize(options.GetTypeInfo()) ?? new ParsedMessage(), - Meta = root.TryGetProperty("meta", out var meta) && meta.ValueKind is not JsonValueKind.Null - ? meta.Deserialize(options.GetTypeInfo()) - : null, - Slot = root.TryGetProperty("slot", out var slot) && slot.ValueKind is JsonValueKind.Number - ? slot.GetUInt64() - : null, - BlockTime = root.TryGetProperty("blockTime", out var blockTime) && blockTime.ValueKind is JsonValueKind.Number - ? blockTime.GetInt64() + Signatures = parsedSignatures, + Message = parsedMessage, + Meta = parsedMeta, + Slot = parsedSlot, + BlockTime = parsedBlockTime, + TransactionIndex = parsedTransactionIndex, + Version = root.TryGetProperty("version", out var version) && version.ValueKind is not JsonValueKind.Null + ? version.Deserialize(options.GetTypeInfo()) : null }; } diff --git a/src/SolSharp.Rpc/Models/Parsed/ParsedTransactionMeta.cs b/src/SolSharp.Rpc/Models/Parsed/ParsedTransactionMeta.cs index 44e8cbb..a81f5de 100644 --- a/src/SolSharp.Rpc/Models/Parsed/ParsedTransactionMeta.cs +++ b/src/SolSharp.Rpc/Models/Parsed/ParsedTransactionMeta.cs @@ -8,23 +8,53 @@ namespace SolSharp.Rpc.Models.Parsed; /// instructions, and any error. Collections are nullable because the node omits or nulls some of them. /// /// getTransaction -public sealed record ParsedTransactionMeta +public sealed record ParsedTransactionMeta : IJsonOnDeserialized { + private JsonElement _status; + private IReadOnlyList? _preBalances; + private IReadOnlyList? _postBalances; + /// The transaction error, or null if it succeeded. [JsonPropertyName("err")] + [JsonRequired] public JsonElement? Err { get; init; } + /// + /// The deprecated result-shaped status field retained by the node for compatibility; prefer + /// or for new code. + /// + [JsonPropertyName("status")] + [JsonRequired] + public JsonElement Status + { + get => _status; + init => _status = value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined + ? throw new JsonException("Parsed transaction metadata must carry a non-null status.") + : value; + } + /// The fee charged, in lamports. [JsonPropertyName("fee")] + [JsonRequired] public ulong Fee { get; init; } /// Account lamport balances before the transaction, indexed by the message's account list. [JsonPropertyName("preBalances")] - public IReadOnlyList? PreBalances { get; init; } + [JsonRequired] + public IReadOnlyList PreBalances + { + get => _preBalances!; + init => _preBalances = value ?? throw new JsonException("Parsed transaction metadata must carry pre-balances."); + } /// Account lamport balances after the transaction, indexed by the message's account list. [JsonPropertyName("postBalances")] - public IReadOnlyList? PostBalances { get; init; } + [JsonRequired] + public IReadOnlyList PostBalances + { + get => _postBalances!; + init => _postBalances = value ?? throw new JsonException("Parsed transaction metadata must carry post-balances."); + } /// The inner (CPI) instructions invoked, grouped by their top-level instruction; null if the node omitted them. [JsonPropertyName("innerInstructions")] @@ -46,6 +76,22 @@ public sealed record ParsedTransactionMeta [JsonPropertyName("loadedAddresses")] public LoadedAddresses? LoadedAddresses { get; init; } + /// The compute units the transaction consumed, when reported. + [JsonPropertyName("computeUnitsConsumed")] + public ulong? ComputeUnitsConsumed { get; init; } + + /// The transaction cost units, when reported. + [JsonPropertyName("costUnits")] + public ulong? CostUnits { get; init; } + + /// Data returned by a program, or null when no program set return data. + [JsonPropertyName("returnData")] + public TransactionReturnData? ReturnData { get; init; } + + /// Rewards and debits recorded while processing the transaction, when reported. + [JsonPropertyName("rewards")] + public IReadOnlyList? Rewards { get; init; } + /// True when the transaction failed ( is present). [JsonIgnore] public bool IsError => Err is { ValueKind: not JsonValueKind.Null }; @@ -53,4 +99,15 @@ public sealed record ParsedTransactionMeta /// The decoded transaction error, or null if it succeeded. [JsonIgnore] public TransactionError? Error => TransactionError.Parse(Err); + + /// + public void OnDeserialized() + { + TransactionStatusValidator.Validate(Err, Status); + RpcCollectionValidator.ValidateOptional(InnerInstructions, "parsed inner-instruction groups"); + RpcCollectionValidator.ValidateOptional(LogMessages, "parsed log messages"); + RpcCollectionValidator.ValidateOptional(PreTokenBalances, "parsed pre-token balances"); + RpcCollectionValidator.ValidateOptional(PostTokenBalances, "parsed post-token balances"); + RpcCollectionValidator.ValidateOptional(Rewards, "parsed rewards"); + } } diff --git a/src/SolSharp.Rpc/Models/PerformanceSample.cs b/src/SolSharp.Rpc/Models/PerformanceSample.cs index 3f7c004..f3fbb34 100644 --- a/src/SolSharp.Rpc/Models/PerformanceSample.cs +++ b/src/SolSharp.Rpc/Models/PerformanceSample.cs @@ -8,10 +8,12 @@ public sealed record PerformanceSample { /// The slot the sample was taken at. [JsonPropertyName("slot")] + [JsonRequired] public ulong Slot { get; init; } /// The number of transactions processed during the sample period. [JsonPropertyName("numTransactions")] + [JsonRequired] public ulong NumTransactions { get; init; } /// The number of non-vote transactions during the sample period, if the node reports it. @@ -20,9 +22,11 @@ public sealed record PerformanceSample /// The number of slots completed during the sample period. [JsonPropertyName("numSlots")] + [JsonRequired] public ulong NumSlots { get; init; } /// The number of seconds in the sample window. [JsonPropertyName("samplePeriodSecs")] + [JsonRequired] public ushort SamplePeriodSecs { get; init; } } diff --git a/src/SolSharp.Rpc/Models/PrioritizationFee.cs b/src/SolSharp.Rpc/Models/PrioritizationFee.cs index a2e9124..cbe4b86 100644 --- a/src/SolSharp.Rpc/Models/PrioritizationFee.cs +++ b/src/SolSharp.Rpc/Models/PrioritizationFee.cs @@ -8,9 +8,11 @@ public sealed record PrioritizationFee { /// The slot the fee was observed in. [JsonPropertyName("slot")] + [JsonRequired] public ulong Slot { get; init; } /// The prioritization fee paid, in micro-lamports per compute unit. [JsonPropertyName("prioritizationFee")] + [JsonRequired] public ulong Fee { get; init; } } diff --git a/src/SolSharp.Rpc/Models/ProgramAccount.cs b/src/SolSharp.Rpc/Models/ProgramAccount.cs index ced7b8e..9f78056 100644 --- a/src/SolSharp.Rpc/Models/ProgramAccount.cs +++ b/src/SolSharp.Rpc/Models/ProgramAccount.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; using SolSharp.Core.Primitives; @@ -7,11 +8,17 @@ namespace SolSharp.Rpc.Models; /// getProgramAccounts public sealed record ProgramAccount { + private AccountInfo? _account; + /// The account's address. [JsonPropertyName("pubkey")] public required PublicKey PublicKey { get; init; } /// The account itself: lamports, owner, decoded data, and so on. [JsonPropertyName("account")] - public required AccountInfo Account { get; init; } + public required AccountInfo Account + { + get => _account!; + init => _account = value ?? throw new JsonException("A keyed account must carry a non-null account."); + } } diff --git a/src/SolSharp.Rpc/Models/Reward.cs b/src/SolSharp.Rpc/Models/Reward.cs new file mode 100644 index 0000000..7af34d9 --- /dev/null +++ b/src/SolSharp.Rpc/Models/Reward.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc.Models; + +/// A reward or debit recorded in transaction or block metadata. +public sealed record Reward +{ + private string? _rewardType; + + /// The rewarded account. + [JsonPropertyName("pubkey")] + [JsonRequired] + public PublicKey PublicKey { get; init; } + + /// The signed balance change in lamports; negative values are debits. + [JsonPropertyName("lamports")] + [JsonRequired] + public long Lamports { get; init; } + + /// The account balance after applying . + [JsonPropertyName("postBalance")] + [JsonRequired] + public ulong PostBalance { get; init; } + + /// + /// The pinned reward variant: Fee, Rent, Staking, Voting, + /// DeactivatedStake, or null. + /// + [JsonPropertyName("rewardType")] + public string? RewardType + { + get => _rewardType; + init + { + if (value is not null and not "Fee" and not "Rent" and not "Staking" and not "Voting" and not "DeactivatedStake") + throw new JsonException("Unknown reward type."); + + _rewardType = value; + } + } + + /// The legacy percentage commission for voting or staking rewards, or null. + [JsonPropertyName("commission")] + public byte? Commission { get; init; } + + /// The commission in basis points, when reported by nodes supporting SIMD-0291. + [JsonPropertyName("commissionBps")] + public ushort? CommissionBps { get; init; } +} diff --git a/src/SolSharp.Rpc/Models/RpcAccountDataJsonConverter.cs b/src/SolSharp.Rpc/Models/RpcAccountDataJsonConverter.cs new file mode 100644 index 0000000..a5e1971 --- /dev/null +++ b/src/SolSharp.Rpc/Models/RpcAccountDataJsonConverter.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SolSharp.Rpc.Models; + +/// Reads and writes the untagged upstream account-data union. +public sealed class RpcAccountDataJsonConverter : JsonConverter +{ + /// + public override bool HandleNull => true; + + /// + public override RpcAccountData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var data = document.RootElement; + + if (data.ValueKind is JsonValueKind.String) + return new RpcAccountData.LegacyBinary(data.GetString()!); + + if (data.ValueKind is JsonValueKind.Array) + return ReadEncoded(data); + + if (data.ValueKind is JsonValueKind.Object) + return ReadParsed(data); + + throw new JsonException("Expected account data as a legacy string, an encoded tuple, or a parsed object."); + } + + /// + public override void Write(Utf8JsonWriter writer, RpcAccountData value, JsonSerializerOptions options) + { + if (value is null) + throw new JsonException("Account data cannot be null."); + + switch (value) + { + case RpcAccountData.LegacyBinary legacy: + writer.WriteStringValue(legacy.EncodedData); + return; + case RpcAccountData.Encoded encoded: + writer.WriteStartArray(); + writer.WriteStringValue(encoded.EncodedData); + writer.WriteStringValue(RpcWireNames.AccountEncoding(encoded.Encoding)); + writer.WriteEndArray(); + return; + case RpcAccountData.Parsed parsed: + writer.WriteStartObject(); + writer.WriteString("program", parsed.Program); + writer.WritePropertyName("parsed"); + parsed.Value.WriteTo(writer); + writer.WriteNumber("space", parsed.Space); + writer.WriteEndObject(); + return; + default: + throw new JsonException($"Unsupported account-data branch {value.GetType().FullName}."); + } + } + + private static RpcAccountData.Encoded ReadEncoded(JsonElement data) + { + if (data.GetArrayLength() != 2 || + data[0].ValueKind is not JsonValueKind.String || + data[1].ValueKind is not JsonValueKind.String) + { + throw new JsonException("Expected account data as a two-string [data, encoding] tuple."); + } + + var wireEncoding = data[1].GetString(); + if (!RpcWireNames.TryAccountEncoding(wireEncoding, out var encoding)) + throw new JsonException($"Unknown account data encoding '{wireEncoding}'."); + + return new RpcAccountData.Encoded(data[0].GetString()!, encoding); + } + + private static RpcAccountData.Parsed ReadParsed(JsonElement data) + { + if (!data.TryGetProperty("program", out var program) || program.ValueKind is not JsonValueKind.String || + !data.TryGetProperty("parsed", out var parsed) || + !data.TryGetProperty("space", out var space) || space.ValueKind is not JsonValueKind.Number || + !space.TryGetUInt64(out var parsedSpace)) + { + throw new JsonException("Expected parsed account data with program, parsed, and unsigned space fields."); + } + + return new RpcAccountData.Parsed(program.GetString()!, parsed.Clone(), parsedSpace); + } +} diff --git a/src/SolSharp.Rpc/Models/RpcAccountInfo.cs b/src/SolSharp.Rpc/Models/RpcAccountInfo.cs new file mode 100644 index 0000000..9e4d932 --- /dev/null +++ b/src/SolSharp.Rpc/Models/RpcAccountInfo.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc.Models; + +/// +/// The exact untagged account-data union returned by Agave. Inspect the runtime branch to distinguish +/// legacy binary, explicitly encoded, and node-parsed data. +/// +[JsonConverter(typeof(RpcAccountDataJsonConverter))] +public abstract record RpcAccountData +{ + private RpcAccountData() + { + } + + /// The legacy binary response: a bare base58 string without an encoding tag. + public sealed record LegacyBinary : RpcAccountData + { + internal LegacyBinary(string encodedData) + { + EncodedData = encodedData; + } + + /// The base58 account data, or the upstream size-error text for oversized data. + public string EncodedData { get; } + } + + /// An explicitly encoded [data, encoding] tuple. + public sealed record Encoded : RpcAccountData + { + internal Encoded(string encodedData, RpcAccountEncoding encoding) + { + EncodedData = encodedData; + Encoding = encoding; + } + + /// The encoded account data exactly as returned by the node. + public string EncodedData { get; } + + /// The encoding tag carried by the tuple. + public RpcAccountEncoding Encoding { get; } + } + + /// A node-decoded account owned by a recognized program. + public sealed record Parsed : RpcAccountData + { + internal Parsed(string program, JsonElement value, ulong space) + { + Program = program; + Value = value; + Space = space; + } + + /// The owning program's short parser name, for example spl-token. + public string Program { get; } + + /// The program-specific parsed JSON payload. + public JsonElement Value { get; } + + /// The account-data size reported by the parsed payload. + public ulong Space { get; } + } +} + +/// An account response that preserves every upstream account-data encoding branch. +public sealed record RpcAccountInfo +{ + /// The account's lamport balance. + [JsonPropertyName("lamports")] + public required ulong Lamports { get; init; } + + /// The program that owns the account. + [JsonPropertyName("owner")] + public required PublicKey Owner { get; init; } + + /// Whether the account holds an executable program. + [JsonPropertyName("executable")] + public required bool Executable { get; init; } + + /// The epoch at which the account will next owe rent. + [JsonPropertyName("rentEpoch")] + public required ulong RentEpoch { get; init; } + + /// The complete account-data length before a requested slice was applied, when reported. + [JsonPropertyName("space")] + public ulong? Space { get; init; } + + /// The account data in the exact branch returned by the node. + [JsonPropertyName("data")] + public required RpcAccountData Data { get; init; } +} + +/// An account paired with its address, as returned by program and token-account scans. +public sealed record RpcProgramAccount +{ + private RpcAccountInfo? _account; + + /// The account address. + [JsonPropertyName("pubkey")] + public required PublicKey PublicKey { get; init; } + + /// The account with its exact upstream data branch. + [JsonPropertyName("account")] + public required RpcAccountInfo Account + { + get => _account!; + init => _account = value ?? throw new JsonException("A keyed RPC account must carry a non-null account."); + } +} diff --git a/src/SolSharp.Rpc/Models/RpcVersion.cs b/src/SolSharp.Rpc/Models/RpcVersion.cs index ba238d7..a52bb59 100644 --- a/src/SolSharp.Rpc/Models/RpcVersion.cs +++ b/src/SolSharp.Rpc/Models/RpcVersion.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace SolSharp.Rpc.Models; @@ -6,11 +7,18 @@ namespace SolSharp.Rpc.Models; /// getVersion public sealed record RpcVersion { + private string? _solanaCore; + /// The solana-core software version string (for example "1.18.0"). [JsonPropertyName("solana-core")] - public string SolanaCore { get; init; } = string.Empty; + [JsonRequired] + public string SolanaCore + { + get => _solanaCore ?? throw new InvalidOperationException("The node version has not been initialized."); + init => _solanaCore = value ?? throw new JsonException("A version response must carry solana-core."); + } /// The numeric feature set the node has enabled, if reported. [JsonPropertyName("feature-set")] - public long? FeatureSet { get; init; } + public uint? FeatureSet { get; init; } } diff --git a/src/SolSharp.Rpc/Models/SignatureInfo.cs b/src/SolSharp.Rpc/Models/SignatureInfo.cs index 0ce2586..3b9a24c 100644 --- a/src/SolSharp.Rpc/Models/SignatureInfo.cs +++ b/src/SolSharp.Rpc/Models/SignatureInfo.cs @@ -7,29 +7,56 @@ namespace SolSharp.Rpc.Models; /// getSignaturesForAddress public sealed record SignatureInfo { + private string? _signature; + private string? _confirmationStatus; + /// The transaction signature, base58. [JsonPropertyName("signature")] - public string Signature { get; init; } = string.Empty; + [JsonRequired] + public string Signature + { + get => _signature ?? throw new InvalidOperationException("The signature entry has not been initialized."); + init => _signature = value ?? throw new JsonException("A signature entry must carry its signature."); + } /// The slot the transaction was processed in. [JsonPropertyName("slot")] + [JsonRequired] public ulong Slot { get; init; } /// The transaction error, or null if it succeeded. [JsonPropertyName("err")] + [JsonRequired] public JsonElement? Err { get; init; } /// The memo attached to the transaction, or null if there was none. [JsonPropertyName("memo")] + [JsonRequired] public string? Memo { get; init; } /// The estimated production time as Unix seconds, or null if not available. [JsonPropertyName("blockTime")] + [JsonRequired] public long? BlockTime { get; init; } /// The cluster confirmation status (processed, confirmed, or finalized), if present. [JsonPropertyName("confirmationStatus")] - public string? ConfirmationStatus { get; init; } + [JsonRequired] + public string? ConfirmationStatus + { + get => _confirmationStatus; + init + { + if (value is not null and not ("processed" or "confirmed" or "finalized")) + throw new JsonException($"Unknown transaction confirmation status '{value}'."); + + _confirmationStatus = value; + } + } + + /// The transaction's index within its block, when reported. + [JsonPropertyName("transactionIndex")] + public uint? TransactionIndex { get; init; } /// True when the transaction failed ( is present). [JsonIgnore] diff --git a/src/SolSharp.Rpc/Models/SignatureStatus.cs b/src/SolSharp.Rpc/Models/SignatureStatus.cs index 319f882..803ddd1 100644 --- a/src/SolSharp.Rpc/Models/SignatureStatus.cs +++ b/src/SolSharp.Rpc/Models/SignatureStatus.cs @@ -5,23 +5,71 @@ namespace SolSharp.Rpc.Models; /// The processing status of a transaction signature, as returned by getSignatureStatuses. /// getSignatureStatuses -public sealed record SignatureStatus +public sealed record SignatureStatus : IJsonOnDeserialized { + private string? _confirmationStatus; + private JsonElement? _status; + private bool _statusIndicatesError; + /// The slot the transaction was processed in. [JsonPropertyName("slot")] + [JsonRequired] public ulong Slot { get; init; } /// The number of blocks since confirmation, or null once the transaction is finalized (rooted). [JsonPropertyName("confirmations")] + [JsonRequired] public ulong? Confirmations { get; init; } /// The transaction error, or null if it succeeded. [JsonPropertyName("err")] + [JsonRequired] public JsonElement? Err { get; init; } + /// + /// The deprecated result-shaped status field retained by the node for compatibility; prefer + /// or for new code. + /// + [JsonPropertyName("status")] + [JsonRequired] + public JsonElement? Status + { + get => _status; + init + { + if (value is not { ValueKind: JsonValueKind.Object } status) + { + throw new JsonException("A signature status must carry exactly one Result branch, Ok or Err."); + } + + var hasOk = status.TryGetProperty("Ok", out var ok); + var hasError = status.TryGetProperty(nameof(Err), out var error); + if (status.EnumerateObject().Count() != 1 || + hasOk == hasError || + (hasOk && ok.ValueKind is not JsonValueKind.Null) || + (hasError && error.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)) + { + throw new JsonException("A signature status must carry exactly one canonical Result branch, Ok or Err."); + } + + _status = value; + _statusIndicatesError = hasError; + } + } + /// The cluster confirmation level reached: processed, confirmed, or finalized. [JsonPropertyName("confirmationStatus")] - public string? ConfirmationStatus { get; init; } + public string? ConfirmationStatus + { + get => _confirmationStatus; + init + { + if (value is not null and not ("processed" or "confirmed" or "finalized")) + throw new JsonException($"Unknown transaction confirmation status '{value}'."); + + _confirmationStatus = value; + } + } /// True when the transaction failed ( is present). [JsonIgnore] @@ -30,4 +78,11 @@ public sealed record SignatureStatus /// The decoded transaction error, or null if it succeeded. [JsonIgnore] public TransactionError? Error => TransactionError.Parse(Err); + + /// + public void OnDeserialized() + { + if (_statusIndicatesError != IsError) + throw new JsonException("A signature status carried inconsistent status and err fields."); + } } diff --git a/src/SolSharp.Rpc/Models/SimulateTransactionResult.cs b/src/SolSharp.Rpc/Models/SimulateTransactionResult.cs index 5e18f8f..03a952e 100644 --- a/src/SolSharp.Rpc/Models/SimulateTransactionResult.cs +++ b/src/SolSharp.Rpc/Models/SimulateTransactionResult.cs @@ -1,14 +1,16 @@ using System.Text.Json; using System.Text.Json.Serialization; +using SolSharp.Rpc.Models.Parsed; namespace SolSharp.Rpc.Models; /// The result of simulating a transaction. /// simulateTransaction -public sealed record SimulateTransactionResult +public sealed record SimulateTransactionResult : IJsonOnDeserialized { /// The transaction error, or null if the simulation succeeded. [JsonPropertyName("err")] + [JsonRequired] public JsonElement? Err { get; init; } /// The log lines the transaction emitted, or null if the node returned none. @@ -19,6 +21,53 @@ public sealed record SimulateTransactionResult [JsonPropertyName("unitsConsumed")] public ulong? UnitsConsumed { get; init; } + /// The total loaded-account data size in bytes, when reported. + [JsonPropertyName("loadedAccountsDataSize")] + public uint? LoadedAccountsDataSize { get; init; } + + /// + /// The requested post-simulation account states in their exact encoded or parsed branch, or null + /// when none were requested. Individual entries are null when simulation failed or an account is absent. + /// + [JsonPropertyName("accounts")] + public IReadOnlyList? Accounts { get; init; } + + /// Data returned by a program, or null when no program set return data. + [JsonPropertyName("returnData")] + public TransactionReturnData? ReturnData { get; init; } + + /// Parsed inner instructions, when requested. + [JsonPropertyName("innerInstructions")] + public IReadOnlyList? InnerInstructions { get; init; } + + /// The replacement blockhash used when recent-blockhash replacement was requested. + [JsonPropertyName("replacementBlockhash")] + public LatestBlockhash? ReplacementBlockhash { get; init; } + + /// The fee the simulated transaction would pay, when reported. + [JsonPropertyName("fee")] + public ulong? Fee { get; init; } + + /// Account lamport balances before simulation, when reported. + [JsonPropertyName("preBalances")] + public IReadOnlyList? PreBalances { get; init; } + + /// Account lamport balances after simulation, when reported. + [JsonPropertyName("postBalances")] + public IReadOnlyList? PostBalances { get; init; } + + /// SPL token balances before simulation, when reported. + [JsonPropertyName("preTokenBalances")] + public IReadOnlyList? PreTokenBalances { get; init; } + + /// SPL token balances after simulation, when reported. + [JsonPropertyName("postTokenBalances")] + public IReadOnlyList? PostTokenBalances { get; init; } + + /// Addresses loaded from lookup tables by a versioned transaction. + [JsonPropertyName("loadedAddresses")] + public LoadedAddresses? LoadedAddresses { get; init; } + /// True when the simulation reported an error ( is present). [JsonIgnore] public bool IsError => Err is { ValueKind: not JsonValueKind.Null }; @@ -26,4 +75,13 @@ public sealed record SimulateTransactionResult /// The decoded transaction error, or null if the simulation succeeded. [JsonIgnore] public TransactionError? Error => TransactionError.Parse(Err); + + /// + public void OnDeserialized() + { + RpcCollectionValidator.ValidateOptional(Logs, "simulation log messages"); + RpcCollectionValidator.ValidateOptional(InnerInstructions, "simulation inner-instruction groups"); + RpcCollectionValidator.ValidateOptional(PreTokenBalances, "simulation pre-token balances"); + RpcCollectionValidator.ValidateOptional(PostTokenBalances, "simulation post-token balances"); + } } diff --git a/src/SolSharp.Rpc/Models/SplLayout.cs b/src/SolSharp.Rpc/Models/SplLayout.cs index 9677fc1..e3ba8fa 100644 --- a/src/SolSharp.Rpc/Models/SplLayout.cs +++ b/src/SolSharp.Rpc/Models/SplLayout.cs @@ -9,13 +9,39 @@ namespace SolSharp.Rpc.Models; /// internal static class SplLayout { - public static PublicKey? ReadCOptionPublicKey(ReadOnlySpan data, int offset) - => BinaryPrimitives.ReadUInt32LittleEndian(data[offset..]) == 1 - ? new PublicKey(data.Slice(offset + sizeof(uint), PublicKey.Length)) - : null; + public static bool TryReadCOptionPublicKey(ReadOnlySpan data, int offset, out PublicKey? value) + { + value = null; + if (offset < 0 || data.Length - offset < sizeof(uint) + PublicKey.Length) + return false; - public static ulong? ReadCOptionU64(ReadOnlySpan data, int offset) - => BinaryPrimitives.ReadUInt32LittleEndian(data[offset..]) == 1 - ? BinaryPrimitives.ReadUInt64LittleEndian(data[(offset + sizeof(uint))..]) - : null; + switch (BinaryPrimitives.ReadUInt32LittleEndian(data[offset..])) + { + case 0: + return true; + case 1: + value = new PublicKey(data.Slice(offset + sizeof(uint), PublicKey.Length)); + return true; + default: + return false; + } + } + + public static bool TryReadCOptionU64(ReadOnlySpan data, int offset, out ulong? value) + { + value = null; + if (offset < 0 || data.Length - offset < sizeof(uint) + sizeof(ulong)) + return false; + + switch (BinaryPrimitives.ReadUInt32LittleEndian(data[offset..])) + { + case 0: + return true; + case 1: + value = BinaryPrimitives.ReadUInt64LittleEndian(data[(offset + sizeof(uint))..]); + return true; + default: + return false; + } + } } diff --git a/src/SolSharp.Rpc/Models/Supply.cs b/src/SolSharp.Rpc/Models/Supply.cs index 084cdf4..ef28883 100644 --- a/src/SolSharp.Rpc/Models/Supply.cs +++ b/src/SolSharp.Rpc/Models/Supply.cs @@ -1,4 +1,6 @@ +using System.Text.Json; using System.Text.Json.Serialization; +using SolSharp.Core.Primitives; namespace SolSharp.Rpc.Models; @@ -6,15 +8,34 @@ namespace SolSharp.Rpc.Models; /// getSupply public sealed record Supply { + private IReadOnlyList? _nonCirculatingAccounts; + /// The total supply. [JsonPropertyName("total")] + [JsonRequired] public ulong Total { get; init; } /// The circulating supply. [JsonPropertyName("circulating")] + [JsonRequired] public ulong Circulating { get; init; } /// The non-circulating supply. [JsonPropertyName("nonCirculating")] + [JsonRequired] public ulong NonCirculating { get; init; } + + /// + /// Accounts excluded from circulating supply, or an empty list when the request set + /// excludeNonCirculatingAccountsList. + /// + [JsonPropertyName("nonCirculatingAccounts")] + [JsonRequired] + public IReadOnlyList NonCirculatingAccounts + { + get => _nonCirculatingAccounts ?? + throw new InvalidOperationException("The non-circulating account list has not been initialized."); + init => _nonCirculatingAccounts = value + ?? throw new JsonException("A supply result must carry its non-circulating account list."); + } } diff --git a/src/SolSharp.Rpc/Models/Token2022/TokenExtensionSet.cs b/src/SolSharp.Rpc/Models/Token2022/TokenExtensionSet.cs index 61ce979..2524cc9 100644 --- a/src/SolSharp.Rpc/Models/Token2022/TokenExtensionSet.cs +++ b/src/SolSharp.Rpc/Models/Token2022/TokenExtensionSet.cs @@ -16,6 +16,7 @@ public sealed record TokenExtensionSet // 165-byte base, and the TLV data follows the account type. private const int AccountTypeIndex = 165; private const int TlvStartIndex = AccountTypeIndex + 1; + private const int MultisigLength = 355; private const byte MintAccountType = 1; private const byte TokenAccountType = 2; @@ -61,49 +62,53 @@ public sealed record TokenExtensionSet /// The mint's transfer-fee schedule and authorities, or null when the extension is absent. /// The decoded , or null. public TransferFeeConfig? GetTransferFeeConfig() - => Find(ExtensionType.TransferFeeConfig) is { Length: >= TransferFeeConfig.Length } data + => Find(ExtensionType.TransferFeeConfig) is { Length: TransferFeeConfig.Length } data ? TransferFeeConfig.Decode(data) : null; /// The fees withheld on a token account (), or null when absent. /// The withheld amount in base units, or null. public ulong? GetWithheldTransferFee() - => Find(ExtensionType.TransferFeeAmount) is { Length: >= 8 } data + => Find(ExtensionType.TransferFeeAmount) is { Length: sizeof(ulong) } data ? BinaryPrimitives.ReadUInt64LittleEndian(data) : null; /// The mint's close authority (), or null when absent or unset. /// The close authority, or null. public PublicKey? GetMintCloseAuthority() - => Find(ExtensionType.MintCloseAuthority) is { Length: >= PublicKey.Length } data + => Find(ExtensionType.MintCloseAuthority) is { Length: PublicKey.Length } data ? Token2022Layout.ReadOptionalKey(data) : null; /// The mint's permanent delegate (), or null when absent or unset. /// The permanent delegate, or null. public PublicKey? GetPermanentDelegate() - => Find(ExtensionType.PermanentDelegate) is { Length: >= PublicKey.Length } data + => Find(ExtensionType.PermanentDelegate) is { Length: PublicKey.Length } data ? Token2022Layout.ReadOptionalKey(data) : null; - /// The default state for new accounts of the mint (), or null when absent. + /// + /// The default state for new accounts of the mint (), + /// or null when absent or malformed. + /// /// The default account state, or null. public TokenAccountState? GetDefaultAccountState() - => Find(ExtensionType.DefaultAccountState) is { Length: >= 1 } data + => Find(ExtensionType.DefaultAccountState) is { Length: 1 } data && + data[0] <= (byte)TokenAccountState.Frozen ? (TokenAccountState)data[0] : null; /// Whether the account requires inbound transfers to carry a memo (), or null when absent. /// true / false from the extension, or null. public bool? GetMemoTransferRequired() - => Find(ExtensionType.MemoTransfer) is { Length: >= 1 } data + => Find(ExtensionType.MemoTransfer) is { Length: 1 } data ? data[0] != 0 : null; /// The mint's metadata pointer (), or null when absent. /// The decoded , or null. public MetadataPointer? GetMetadataPointer() - => Find(ExtensionType.MetadataPointer) is { Length: >= MetadataPointer.Length } data + => Find(ExtensionType.MetadataPointer) is { Length: MetadataPointer.Length } data ? MetadataPointer.Decode(data) : null; @@ -128,25 +133,30 @@ public sealed record TokenExtensionSet private static TokenExtensionSet? Decode(ReadOnlySpan data, int baseLength, byte expectedAccountType) { - if (data.Length < baseLength) + if (data.Length < baseLength || data.Length == MultisigLength) return null; // A bare (non-extended) account is exactly the base length and has no extension section. if (data.Length == baseLength) return new TokenExtensionSet { Extensions = [] }; - if (data.Length < TlvStartIndex || data[AccountTypeIndex] != expectedAccountType) + if (data.Length < TlvStartIndex + || data[AccountTypeIndex] != expectedAccountType + || (baseLength == Mint.Length && data[Mint.Length..AccountTypeIndex].IndexOfAnyExcept((byte)0) >= 0)) return null; var extensions = new List(); var offset = TlvStartIndex; - while (offset + 2 <= data.Length) + while (offset < data.Length) { + if (data.Length - offset < sizeof(ushort)) + return new TokenExtensionSet { Extensions = extensions }; + var type = BinaryPrimitives.ReadUInt16LittleEndian(data[offset..]); if (type == (ushort)ExtensionType.Uninitialized) - break; // zero padding marks the end of the TLV data + return new TokenExtensionSet { Extensions = extensions }; - if (offset + 4 > data.Length) + if (data.Length - offset < 4) return null; var length = BinaryPrimitives.ReadUInt16LittleEndian(data[(offset + 2)..]); diff --git a/src/SolSharp.Rpc/Models/Token2022/TokenMetadata.cs b/src/SolSharp.Rpc/Models/Token2022/TokenMetadata.cs index 1fdacf1..eab587b 100644 --- a/src/SolSharp.Rpc/Models/Token2022/TokenMetadata.cs +++ b/src/SolSharp.Rpc/Models/Token2022/TokenMetadata.cs @@ -40,6 +40,13 @@ internal static TokenMetadata Decode(ReadOnlySpan data) var uri = reader.ReadString(); var count = reader.ReadLength(); + // Every key/value pair needs at least two four-byte Borsh string length prefixes. Validate the + // attacker-controlled count before using it as List capacity; otherwise a tiny malformed TLV can + // request a multi-gigabyte allocation before the reader gets a chance to reject truncated data. + if (count > reader.Remaining / (sizeof(uint) * 2)) + throw new FormatException( + $"Token metadata declares {count} additional entries but only {reader.Remaining} byte(s) remain."); + var additional = new List>(count); for (var i = 0; i < count; i++) additional.Add(new KeyValuePair(reader.ReadString(), reader.ReadString())); diff --git a/src/SolSharp.Rpc/Models/TokenAccount.cs b/src/SolSharp.Rpc/Models/TokenAccount.cs index 208cedd..028bc0a 100644 --- a/src/SolSharp.Rpc/Models/TokenAccount.cs +++ b/src/SolSharp.Rpc/Models/TokenAccount.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using SolSharp.Core.Primitives; +using SolSharp.Rpc.Models.Token2022; namespace SolSharp.Rpc.Models; @@ -55,10 +56,16 @@ public sealed record TokenAccount /// Decodes a token account from its raw account data (the bytes getAccountInfo returns). /// The account's raw data. - /// The decoded token account, or null if the data is too short to be a token account. + /// The decoded account, or null if the data is not a canonical Token or Token-2022 account layout. public static TokenAccount? Decode(ReadOnlySpan data) { - if (data.Length < Length) + if (data.Length != Length && TokenExtensionSet.DecodeAccount(data) is null) + return null; + + if (!SplLayout.TryReadCOptionPublicKey(data, 72, out var delegateAuthority) + || data[108] > (byte)TokenAccountState.Frozen + || !SplLayout.TryReadCOptionU64(data, 109, out var isNative) + || !SplLayout.TryReadCOptionPublicKey(data, 129, out var closeAuthority)) return null; return new TokenAccount @@ -66,11 +73,11 @@ public sealed record TokenAccount Mint = new PublicKey(data[..PublicKey.Length]), Owner = new PublicKey(data.Slice(32, PublicKey.Length)), Amount = BinaryPrimitives.ReadUInt64LittleEndian(data[64..]), - Delegate = SplLayout.ReadCOptionPublicKey(data, 72), + Delegate = delegateAuthority, State = (TokenAccountState)data[108], - IsNative = SplLayout.ReadCOptionU64(data, 109), + IsNative = isNative, DelegatedAmount = BinaryPrimitives.ReadUInt64LittleEndian(data[121..]), - CloseAuthority = SplLayout.ReadCOptionPublicKey(data, 129) + CloseAuthority = closeAuthority }; } } diff --git a/src/SolSharp.Rpc/Models/TokenAmount.cs b/src/SolSharp.Rpc/Models/TokenAmount.cs index 36610aa..dc44b91 100644 --- a/src/SolSharp.Rpc/Models/TokenAmount.cs +++ b/src/SolSharp.Rpc/Models/TokenAmount.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace SolSharp.Rpc.Models; @@ -6,19 +7,34 @@ namespace SolSharp.Rpc.Models; /// getTokenAccountBalance public sealed record TokenAmount { + private string? _amount; + private string? _uiAmountString; + /// The raw amount in the token's base units. [JsonPropertyName("amount")] - public string Amount { get; init; } = string.Empty; + [JsonRequired] + public string Amount + { + get => _amount ?? throw new InvalidOperationException("The token amount has not been initialized."); + init => _amount = value ?? throw new JsonException("A token amount must carry its base-unit string."); + } /// The number of base-10 digits to the right of the decimal point. [JsonPropertyName("decimals")] - public int Decimals { get; init; } + [JsonRequired] + public byte Decimals { get; init; } /// The amount in UI units, or null if it cannot be represented. [JsonPropertyName("uiAmount")] - public decimal? UiAmount { get; init; } + [JsonRequired] + public double? UiAmount { get; init; } /// The amount in UI units as a string. [JsonPropertyName("uiAmountString")] - public string? UiAmountString { get; init; } + [JsonRequired] + public string UiAmountString + { + get => _uiAmountString ?? throw new InvalidOperationException("The UI token amount has not been initialized."); + init => _uiAmountString = value ?? throw new JsonException("A token amount must carry its UI amount string."); + } } diff --git a/src/SolSharp.Rpc/Models/TokenLargestAccount.cs b/src/SolSharp.Rpc/Models/TokenLargestAccount.cs index ffd045b..3a6025c 100644 --- a/src/SolSharp.Rpc/Models/TokenLargestAccount.cs +++ b/src/SolSharp.Rpc/Models/TokenLargestAccount.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; using SolSharp.Core.Primitives; @@ -7,19 +8,39 @@ namespace SolSharp.Rpc.Models; /// getTokenLargestAccounts public sealed record TokenLargestAccount { + private string? _amount; + private string? _uiAmountString; + /// The token account's address. [JsonPropertyName("address")] + [JsonRequired] public PublicKey Address { get; init; } /// The balance in base units, as a string (it can exceed ). [JsonPropertyName("amount")] - public string Amount { get; init; } = "0"; + [JsonRequired] + public string Amount + { + get => _amount ?? throw new InvalidOperationException("The token-account amount has not been initialized."); + init => _amount = value ?? throw new JsonException("A largest-token-account entry must carry its amount."); + } /// The mint's decimals. [JsonPropertyName("decimals")] + [JsonRequired] public byte Decimals { get; init; } + /// The balance scaled by the mint's decimals as a JSON number, or null. + [JsonPropertyName("uiAmount")] + [JsonRequired] + public double? UiAmount { get; init; } + /// The balance scaled by the decimals, as a human-readable string. [JsonPropertyName("uiAmountString")] - public string? UiAmountString { get; init; } + [JsonRequired] + public string UiAmountString + { + get => _uiAmountString ?? throw new InvalidOperationException("The UI token-account amount has not been initialized."); + init => _uiAmountString = value ?? throw new JsonException("A largest-token-account entry must carry its UI amount string."); + } } diff --git a/src/SolSharp.Rpc/Models/TransactionError.cs b/src/SolSharp.Rpc/Models/TransactionError.cs index cb56763..5e452a9 100644 --- a/src/SolSharp.Rpc/Models/TransactionError.cs +++ b/src/SolSharp.Rpc/Models/TransactionError.cs @@ -19,6 +19,20 @@ public sealed record TransactionError /// The instruction-level error, when is InstructionError. public InstructionError? InstructionError { get; init; } + /// + /// The duplicate top-level instruction index, when is DuplicateInstruction. + /// + public int? DuplicateInstructionIndex { get; init; } + + /// + /// The account index, when is InsufficientFundsForRent or + /// ProgramExecutionTemporarilyRestricted. + /// + public int? AccountIndex { get; init; } + + /// The raw payload of a parameterized variant, retained for forward compatibility. + public JsonElement? Details { get; init; } + /// Decodes a node's err value; returns null for a successful transaction (no error). /// The raw err JSON, or null. /// The decoded error, or null when there is none. @@ -42,11 +56,39 @@ public sealed record TransactionError { Kind = member.Name, InstructionIndex = member.Value[0].ValueKind == JsonValueKind.Number ? member.Value[0].GetInt32() : null, - InstructionError = global::SolSharp.Rpc.Models.InstructionError.Parse(member.Value[1]) + InstructionError = global::SolSharp.Rpc.Models.InstructionError.Parse(member.Value[1]), + Details = member.Value.Clone() + }; + } + + if (member.NameEquals("DuplicateInstruction") && + member.Value.ValueKind == JsonValueKind.Number && + member.Value.TryGetInt32(out var duplicateInstructionIndex)) + { + return new TransactionError + { + Kind = member.Name, + DuplicateInstructionIndex = duplicateInstructionIndex, + Details = member.Value.Clone() + }; + } + + if ((member.NameEquals("InsufficientFundsForRent") || + member.NameEquals("ProgramExecutionTemporarilyRestricted")) && + member.Value.ValueKind == JsonValueKind.Object && + member.Value.TryGetProperty("account_index", out var accountIndex) && + accountIndex.ValueKind == JsonValueKind.Number && + accountIndex.TryGetInt32(out var accountIndexValue)) + { + return new TransactionError + { + Kind = member.Name, + AccountIndex = accountIndexValue, + Details = member.Value.Clone() }; } - return new TransactionError { Kind = member.Name }; + return new TransactionError { Kind = member.Name, Details = member.Value.Clone() }; } } @@ -57,7 +99,11 @@ public sealed record TransactionError public override string ToString() => InstructionError is { } inner ? $"InstructionError at instruction {InstructionIndex}: {inner}" - : Kind; + : DuplicateInstructionIndex is { } duplicateInstructionIndex + ? $"DuplicateInstruction at instruction {duplicateInstructionIndex}" + : AccountIndex is { } accountIndex + ? $"{Kind} at account {accountIndex}" + : Kind; } /// An instruction-level error - a named runtime variant, or a program-defined . diff --git a/src/SolSharp.Rpc/Models/TransactionResponse.cs b/src/SolSharp.Rpc/Models/TransactionResponse.cs index 2d097cf..ddfdc4a 100644 --- a/src/SolSharp.Rpc/Models/TransactionResponse.cs +++ b/src/SolSharp.Rpc/Models/TransactionResponse.cs @@ -4,50 +4,167 @@ namespace SolSharp.Rpc.Models; +/// The closed transaction-version union returned by Solana RPC: "legacy" or a numeric u8. +[JsonConverter(typeof(RpcTransactionVersionJsonConverter))] +public readonly record struct RpcTransactionVersion +{ + private const ushort MaximumEncodedNumber = byte.MaxValue + 1; + private const ushort LegacyValue = MaximumEncodedNumber + 1; + + private readonly ushort _value; + + private RpcTransactionVersion(ushort value) => _value = value; + + /// The legacy transaction version. + public static RpcTransactionVersion Legacy => new(LegacyValue); + + /// Whether this value represents a legacy transaction. + public bool IsLegacy => _value == LegacyValue; + + /// The numeric transaction version, or null for a legacy or uninitialized value. + public byte? Number => _value is >= 1 and <= MaximumEncodedNumber ? (byte)(_value - 1) : null; + + /// Creates a numeric transaction version. + /// The numeric u8 transaction version. + /// The corresponding numeric transaction version. + public static RpcTransactionVersion FromNumber(byte number) => new((ushort)(number + 1)); +} + +/// Reads and writes the exact Solana RPC transaction-version union. +public sealed class RpcTransactionVersionJsonConverter : JsonConverter +{ + /// + public override RpcTransactionVersion Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.String && reader.GetString() == "legacy") + return RpcTransactionVersion.Legacy; + + if (reader.TokenType is JsonTokenType.Number && reader.TryGetByte(out var number)) + return RpcTransactionVersion.FromNumber(number); + + throw new JsonException("A transaction version must be \"legacy\" or a u8 integer."); + } + + /// + public override void Write( + Utf8JsonWriter writer, + RpcTransactionVersion value, + JsonSerializerOptions options) + { + if (value.IsLegacy) + { + writer.WriteStringValue("legacy"); + return; + } + + if (value.Number is { } number) + { + writer.WriteNumberValue(number); + return; + } + + throw new JsonException("An uninitialized transaction version cannot be serialized."); + } +} + /// A confirmed transaction as returned by getTransaction: where it landed, its bytes, and how it executed. /// getTransaction public sealed record TransactionResponse { + private byte[]? _transaction; + /// The slot the transaction was processed in. [JsonPropertyName("slot")] + [JsonRequired] public ulong Slot { get; init; } /// The estimated production time as Unix seconds, or null if not available. [JsonPropertyName("blockTime")] + [JsonRequired] public long? BlockTime { get; init; } + /// The transaction's index within the block, when reported. + [JsonPropertyName("transactionIndex")] + public uint? TransactionIndex { get; init; } + /// - /// The transaction's wire bytes, decoded from the node's base64 form; pass to - /// Transaction.Deserialize (in SolSharp.Programs) to read its message, accounts, and instructions. + /// The wire transaction version, or null when the node omitted it. + /// + [JsonPropertyName("version")] + public RpcTransactionVersion? Version { get; init; } + + /// + /// The transaction's wire bytes, decoded from the node's base64 form. Legacy, v0, and V1 bytes can be + /// passed to Transaction.Deserialize (in SolSharp.Programs) to read their messages, accounts, + /// and instructions. /// [JsonPropertyName("transaction")] [JsonConverter(typeof(Base64TupleJsonConverter))] - public byte[]? Transaction { get; init; } + [JsonRequired] + public byte[] Transaction + { + get => _transaction!; + init => _transaction = value ?? throw new JsonException("A transaction response must carry non-null wire bytes."); + } /// Execution metadata: fee, balances, token balances, logs, inner instructions, and any error. [JsonPropertyName("meta")] + [JsonRequired] public TransactionMeta? Meta { get; init; } } /// The execution metadata attached to a confirmed transaction. /// getTransaction -public sealed record TransactionMeta +public sealed record TransactionMeta : IJsonOnDeserialized { + private JsonElement _status; + private IReadOnlyList? _preBalances; + private IReadOnlyList? _postBalances; + /// The transaction error, or null if it succeeded. [JsonPropertyName("err")] + [JsonRequired] public JsonElement? Err { get; init; } + /// + /// The deprecated result-shaped status field retained by the node for compatibility; prefer + /// or for new code. + /// + [JsonPropertyName("status")] + [JsonRequired] + public JsonElement Status + { + get => _status; + init => _status = value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined + ? throw new JsonException("Transaction metadata must carry a non-null status.") + : value; + } + /// The fee charged, in lamports. [JsonPropertyName("fee")] + [JsonRequired] public ulong Fee { get; init; } /// Account lamport balances before the transaction, indexed by the message's account list. [JsonPropertyName("preBalances")] - public IReadOnlyList? PreBalances { get; init; } + [JsonRequired] + public IReadOnlyList PreBalances + { + get => _preBalances!; + init => _preBalances = value ?? throw new JsonException("Transaction metadata must carry pre-balances."); + } /// Account lamport balances after the transaction, indexed by the message's account list. [JsonPropertyName("postBalances")] - public IReadOnlyList? PostBalances { get; init; } + [JsonRequired] + public IReadOnlyList PostBalances + { + get => _postBalances!; + init => _postBalances = value ?? throw new JsonException("Transaction metadata must carry post-balances."); + } /// SPL token balances before the transaction, for the accounts that hold tokens. [JsonPropertyName("preTokenBalances")] @@ -73,6 +190,18 @@ public sealed record TransactionMeta [JsonPropertyName("computeUnitsConsumed")] public ulong? ComputeUnitsConsumed { get; init; } + /// The transaction cost units, when reported. + [JsonPropertyName("costUnits")] + public ulong? CostUnits { get; init; } + + /// Data returned by a program, or null when no program set return data. + [JsonPropertyName("returnData")] + public TransactionReturnData? ReturnData { get; init; } + + /// Rewards and debits recorded while processing the transaction, when reported. + [JsonPropertyName("rewards")] + public IReadOnlyList? Rewards { get; init; } + /// True when the transaction failed ( is present). [JsonIgnore] public bool IsError => Err is { ValueKind: not JsonValueKind.Null }; @@ -80,18 +209,33 @@ public sealed record TransactionMeta /// The decoded transaction error, or null if it succeeded. [JsonIgnore] public TransactionError? Error => TransactionError.Parse(Err); + + /// + public void OnDeserialized() + { + TransactionStatusValidator.Validate(Err, Status); + RpcCollectionValidator.ValidateOptional(PreTokenBalances, "pre-token balances"); + RpcCollectionValidator.ValidateOptional(PostTokenBalances, "post-token balances"); + RpcCollectionValidator.ValidateOptional(InnerInstructions, "inner-instruction groups"); + RpcCollectionValidator.ValidateOptional(LogMessages, "log messages"); + RpcCollectionValidator.ValidateOptional(Rewards, "rewards"); + } } /// A pre- or post-execution SPL token balance snapshot from a transaction's metadata. /// Solana RPC JSON structures public sealed record TokenBalance { + private TokenAmount? _uiTokenAmount; + /// The index, into the transaction's account list, of the token account this balance is for. [JsonPropertyName("accountIndex")] - public int AccountIndex { get; init; } + [JsonRequired] + public byte AccountIndex { get; init; } /// The token mint. [JsonPropertyName("mint")] + [JsonRequired] public PublicKey Mint { get; init; } /// The token account's owner, if the node reported it. @@ -104,52 +248,138 @@ public sealed record TokenBalance /// The balance, in base units and as a UI amount. [JsonPropertyName("uiTokenAmount")] - public TokenAmount UiTokenAmount { get; init; } = new(); + [JsonRequired] + public TokenAmount UiTokenAmount + { + get => _uiTokenAmount!; + init => _uiTokenAmount = value ?? throw new JsonException("A token balance must carry a UI token amount."); + } } /// The inner (CPI) instructions invoked under one top-level instruction. /// Solana RPC JSON structures public sealed record InnerInstructionGroup { + private IReadOnlyList? _instructions; + /// The index of the top-level instruction these inner instructions were invoked from. [JsonPropertyName("index")] - public int Index { get; init; } + [JsonRequired] + public byte Index { get; init; } /// The inner instructions, in invocation order. [JsonPropertyName("instructions")] - public IReadOnlyList Instructions { get; init; } = []; + [JsonRequired] + public IReadOnlyList Instructions + { + get => _instructions!; + init + { + if (value is null || value.Any(static instruction => instruction is null)) + throw new JsonException("Inner instructions must carry only non-null instructions."); + + _instructions = value; + } + } } /// One compiled inner instruction, as returned with base64 transaction encoding. /// Solana RPC JSON structures public sealed record InnerInstruction { + private IReadOnlyList? _accounts; + private string? _data; + /// The index, into the transaction's account list, of the invoked program. [JsonPropertyName("programIdIndex")] - public int ProgramIdIndex { get; init; } + [JsonRequired] + public byte ProgramIdIndex { get; init; } /// The indices, into the transaction's account list, of the accounts passed to the instruction. [JsonPropertyName("accounts")] - public IReadOnlyList Accounts { get; init; } = []; + [JsonRequired] + public IReadOnlyList Accounts + { + get => _accounts!; + init => _accounts = value ?? throw new JsonException("A compiled instruction must carry account indexes."); + } /// The instruction data, base58-encoded. [JsonPropertyName("data")] - public string Data { get; init; } = string.Empty; + [JsonRequired] + public string Data + { + get => _data!; + init => _data = value ?? throw new JsonException("A compiled instruction must carry data."); + } /// The CPI stack height at which the instruction ran, if the node reported it. [JsonPropertyName("stackHeight")] - public int? StackHeight { get; init; } + [JsonRequired] + public uint? StackHeight { get; init; } } /// The accounts a versioned transaction loaded from address lookup tables. /// Solana RPC JSON structures public sealed record LoadedAddresses { + private IReadOnlyList? _writable; + private IReadOnlyList? _readonly; + /// The writable accounts loaded from lookup tables. [JsonPropertyName("writable")] - public IReadOnlyList Writable { get; init; } = []; + [JsonRequired] + public IReadOnlyList Writable + { + get => _writable!; + init => _writable = value ?? throw new JsonException("Loaded addresses must carry a writable list."); + } /// The read-only accounts loaded from lookup tables. [JsonPropertyName("readonly")] - public IReadOnlyList Readonly { get; init; } = []; + [JsonRequired] + public IReadOnlyList Readonly + { + get => _readonly!; + init => _readonly = value ?? throw new JsonException("Loaded addresses must carry a read-only list."); + } +} + +internal static class TransactionStatusValidator +{ + internal static void Validate(JsonElement? error, JsonElement status) + { + if (status.ValueKind is not JsonValueKind.Object) + throw new JsonException("Transaction status must be an Ok or Err object."); + + var properties = status.EnumerateObject().ToArray(); + if (properties.Length != 1) + throw new JsonException("Transaction status must carry exactly one Ok or Err variant."); + + var statusVariant = properties[0]; + var hasError = error is { ValueKind: not JsonValueKind.Null }; + if (statusVariant.NameEquals("Ok")) + { + if (statusVariant.Value.ValueKind is not JsonValueKind.Null || hasError) + throw new JsonException("A successful transaction status must be {\"Ok\":null} with a null err field."); + + return; + } + + if (!statusVariant.NameEquals("Err") || statusVariant.Value.ValueKind is JsonValueKind.Null || !hasError) + throw new JsonException("A failed transaction status must carry the same non-null error as the err field."); + + if (statusVariant.Value.GetRawText() != error!.Value.GetRawText()) + throw new JsonException("Transaction status and err must carry the same error value."); + } +} + +internal static class RpcCollectionValidator +{ + internal static void ValidateOptional(IReadOnlyList? values, string fieldName) + where T : class + { + if (values is not null && values.Any(static value => value is null)) + throw new JsonException($"RPC {fieldName} cannot contain null entries."); + } } diff --git a/src/SolSharp.Rpc/Models/TransactionReturnData.cs b/src/SolSharp.Rpc/Models/TransactionReturnData.cs new file mode 100644 index 0000000..4a9e565 --- /dev/null +++ b/src/SolSharp.Rpc/Models/TransactionReturnData.cs @@ -0,0 +1,26 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc.Models; + +/// Data returned by a program through Solana's transaction return-data syscall. +public sealed record TransactionReturnData +{ + private byte[]? _data; + + /// The program that set the return data. + [JsonPropertyName("programId")] + [JsonRequired] + public PublicKey ProgramId { get; init; } + + /// The returned bytes, decoded from the node's [data, "base64"] tuple. + [JsonPropertyName("data")] + [JsonConverter(typeof(Base64TupleJsonConverter))] + [JsonRequired] + public byte[] Data + { + get => _data!; + init => _data = value ?? throw new JsonException("Transaction return data must carry non-null bytes."); + } +} diff --git a/src/SolSharp.Rpc/Models/VoteAccount.cs b/src/SolSharp.Rpc/Models/VoteAccount.cs index 8ad9195..83f264b 100644 --- a/src/SolSharp.Rpc/Models/VoteAccount.cs +++ b/src/SolSharp.Rpc/Models/VoteAccount.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; using SolSharp.Core.Primitives; @@ -7,48 +8,91 @@ namespace SolSharp.Rpc.Models; /// getVoteAccounts public sealed record VoteAccounts { + private IReadOnlyList? _current; + private IReadOnlyList? _delinquent; + /// Vote accounts that have voted recently enough to be considered active. [JsonPropertyName("current")] - public IReadOnlyList Current { get; init; } = []; + [JsonRequired] + public IReadOnlyList Current + { + get => _current!; + init => _current = RequireVoteAccounts(value, "current"); + } /// Vote accounts that have not voted recently enough (delinquent). [JsonPropertyName("delinquent")] - public IReadOnlyList Delinquent { get; init; } = []; + [JsonRequired] + public IReadOnlyList Delinquent + { + get => _delinquent!; + init => _delinquent = RequireVoteAccounts(value, "delinquent"); + } + + private static IReadOnlyList RequireVoteAccounts( + IReadOnlyList? accounts, + string name) + { + if (accounts is null || accounts.Any(static account => account is null)) + throw new JsonException($"A vote-account response must carry only non-null {name} entries."); + + return accounts; + } } /// A validator's vote account, as returned within getVoteAccounts. /// getVoteAccounts public sealed record VoteAccount { + private IReadOnlyList? _epochCredits; + /// The vote account address. [JsonPropertyName("votePubkey")] + [JsonRequired] public PublicKey VotePubkey { get; init; } /// The validator identity that votes through this account. [JsonPropertyName("nodePubkey")] + [JsonRequired] public PublicKey NodePubkey { get; init; } /// The stake, in lamports, delegated to this vote account and active this epoch. [JsonPropertyName("activatedStake")] + [JsonRequired] public ulong ActivatedStake { get; init; } /// Whether the vote account is staked for the current epoch. [JsonPropertyName("epochVoteAccount")] + [JsonRequired] public bool EpochVoteAccount { get; init; } /// The percentage (0-100) of rewards owed to the validator. [JsonPropertyName("commission")] + [JsonRequired] public byte Commission { get; init; } + /// + /// The raw commission in basis points, when reported by nodes supporting SIMD-0291; otherwise null. + /// + [JsonPropertyName("inflationRewardsCommissionBps")] + public ushort? InflationRewardsCommissionBps { get; init; } + /// The most recent slot this account voted on. [JsonPropertyName("lastVote")] + [JsonRequired] public ulong LastVote { get; init; } /// The current root slot for this vote account. [JsonPropertyName("rootSlot")] + [JsonRequired] public ulong RootSlot { get; init; } /// Recent earned credits per epoch, each entry being [epoch, credits, previousCredits]. [JsonPropertyName("epochCredits")] - public IReadOnlyList> EpochCredits { get; init; } = []; + [JsonRequired] + public IReadOnlyList EpochCredits + { + get => _epochCredits!; + init => _epochCredits = value ?? throw new JsonException("A vote account must carry epoch credits."); + } } diff --git a/src/SolSharp.Rpc/Models/VoteEpochCredit.cs b/src/SolSharp.Rpc/Models/VoteEpochCredit.cs new file mode 100644 index 0000000..7d1ba27 --- /dev/null +++ b/src/SolSharp.Rpc/Models/VoteEpochCredit.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SolSharp.Rpc.Models; + +/// One [epoch, credits, previousCredits] tuple from a vote account RPC response. +[JsonConverter(typeof(VoteEpochCreditJsonConverter))] +public readonly record struct VoteEpochCredit +{ + /// Creates one exact vote-credit tuple. + /// The epoch that earned the credits. + /// Cumulative credits at the end of the epoch. + /// Cumulative credits at the beginning of the epoch. + public VoteEpochCredit(ulong epoch, ulong credits, ulong previousCredits) + { + Epoch = epoch; + Credits = credits; + PreviousCredits = previousCredits; + } + + /// The epoch that earned the credits. + public ulong Epoch { get; } + + /// Cumulative credits at the end of the epoch. + public ulong Credits { get; } + + /// Cumulative credits at the beginning of the epoch. + public ulong PreviousCredits { get; } +} + +/// Reads and writes the exact three-element vote-credit tuple used by Agave. +public sealed class VoteEpochCreditJsonConverter : JsonConverter +{ + /// + public override VoteEpochCredit Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray || + !reader.Read() || reader.TokenType != JsonTokenType.Number || !reader.TryGetUInt64(out var epoch) || + !reader.Read() || reader.TokenType != JsonTokenType.Number || !reader.TryGetUInt64(out var credits) || + !reader.Read() || reader.TokenType != JsonTokenType.Number || !reader.TryGetUInt64(out var previousCredits) || + !reader.Read() || reader.TokenType != JsonTokenType.EndArray) + { + throw new JsonException("A vote epoch-credit value must be exactly [epoch, credits, previousCredits] as u64 values."); + } + + return new VoteEpochCredit(epoch, credits, previousCredits); + } + + /// + public override void Write(Utf8JsonWriter writer, VoteEpochCredit value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + writer.WriteNumberValue(value.Epoch); + writer.WriteNumberValue(value.Credits); + writer.WriteNumberValue(value.PreviousCredits); + writer.WriteEndArray(); + } +} diff --git a/src/SolSharp.Rpc/Protocol/RpcContextValue.cs b/src/SolSharp.Rpc/Protocol/RpcContextValue.cs index beb4a4b..af8d165 100644 --- a/src/SolSharp.Rpc/Protocol/RpcContextValue.cs +++ b/src/SolSharp.Rpc/Protocol/RpcContextValue.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace SolSharp.Rpc.Protocol; @@ -6,12 +7,20 @@ namespace SolSharp.Rpc.Protocol; /// The type of the wrapped . public sealed record RpcContextValue { + private RpcContext? _context; + /// The slot context the result was produced at. [JsonPropertyName("context")] - public RpcContext? Context { get; init; } + [JsonRequired] + public RpcContext Context + { + get => _context ?? throw new InvalidOperationException("The RPC context has not been initialized."); + init => _context = value ?? throw new JsonException("An RPC context wrapper must carry a non-null context."); + } /// The method's actual result value. [JsonPropertyName("value")] + [JsonRequired] public T? Value { get; init; } } @@ -20,5 +29,10 @@ public sealed record RpcContext { /// The slot at which the data was retrieved. [JsonPropertyName("slot")] + [JsonRequired] public ulong Slot { get; init; } + + /// The node's RPC API version, when reported. + [JsonPropertyName("apiVersion")] + public string? ApiVersion { get; init; } } diff --git a/src/SolSharp.Rpc/Protocol/RpcError.cs b/src/SolSharp.Rpc/Protocol/RpcError.cs index e75197c..d4ac035 100644 --- a/src/SolSharp.Rpc/Protocol/RpcError.cs +++ b/src/SolSharp.Rpc/Protocol/RpcError.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace SolSharp.Rpc.Protocol; @@ -12,4 +13,8 @@ internal sealed record RpcError /// The human-readable error message. [JsonPropertyName("message")] public string Message { get; init; } = string.Empty; + + /// Structured node-specific diagnostics, when supplied. + [JsonPropertyName("data")] + public JsonElement? Data { get; init; } } diff --git a/src/SolSharp.Rpc/Protocol/RpcException.cs b/src/SolSharp.Rpc/Protocol/RpcException.cs index f740c61..a38cd14 100644 --- a/src/SolSharp.Rpc/Protocol/RpcException.cs +++ b/src/SolSharp.Rpc/Protocol/RpcException.cs @@ -1,8 +1,23 @@ +using System.Text.Json; + namespace SolSharp.Rpc.Protocol; /// Thrown when the node returns a JSON-RPC error. public sealed class RpcException(int code, string message) : Exception($"RPC error {code}: {message}") { + /// Creates an exception and preserves the node's structured error data. + /// The JSON-RPC error code. + /// The node's human-readable message. + /// The optional JSON-RPC error.data value. + internal RpcException(int code, string message, JsonElement? errorData) + : this(code, message) + { + ErrorData = errorData?.Clone(); + } + /// The JSON-RPC error code returned by the node. public int Code { get; } = code; + + /// The optional structured JSON-RPC error.data diagnostics returned by the node. + public JsonElement? ErrorData { get; } } diff --git a/src/SolSharp.Rpc/Protocol/RpcParams.cs b/src/SolSharp.Rpc/Protocol/RpcParams.cs index 726c292..fa69e61 100644 --- a/src/SolSharp.Rpc/Protocol/RpcParams.cs +++ b/src/SolSharp.Rpc/Protocol/RpcParams.cs @@ -15,6 +15,16 @@ internal sealed record CommitmentConfig public Commitment? Commitment { get; init; } } +/// The { commitment, minContextSlot } configuration used by context-aware read methods. +internal sealed record ContextConfig +{ + [JsonPropertyName("commitment")] + public Commitment? Commitment { get; init; } + + [JsonPropertyName("minContextSlot")] + public ulong? MinContextSlot { get; init; } +} + /// The sendTransaction configuration object. internal sealed record SendTransactionConfig { @@ -51,6 +61,22 @@ internal sealed record SimulateTransactionConfig [JsonPropertyName("minContextSlot")] public ulong? MinContextSlot { get; init; } + + [JsonPropertyName("accounts")] + public SimulateTransactionAccountsConfig? Accounts { get; init; } + + [JsonPropertyName("innerInstructions")] + public bool? InnerInstructions { get; init; } +} + +/// The optional post-simulation accounts requested from simulateTransaction. +internal sealed record SimulateTransactionAccountsConfig +{ + [JsonPropertyName("encoding")] + public required string Encoding { get; init; } + + [JsonPropertyName("addresses")] + public required PublicKey[] Addresses { get; init; } } /// @@ -61,13 +87,16 @@ internal sealed record SimulateTransactionConfig internal sealed record AccountInfoConfig { [JsonPropertyName("encoding")] - public required string Encoding { get; init; } + public string? Encoding { get; init; } [JsonPropertyName("commitment")] public Commitment? Commitment { get; init; } [JsonPropertyName("dataSlice")] public DataSlice? DataSlice { get; init; } + + [JsonPropertyName("minContextSlot")] + public ulong? MinContextSlot { get; init; } } /// The getSignaturesForAddress configuration object. @@ -91,13 +120,13 @@ internal sealed record SignaturesForAddressConfig /// /// The getProgramAccounts / programSubscribe configuration object (the subscription leaves -/// and unset). Filter entries are the payload records -/// built by . +/// , , , and +/// unset). Filter entries are the payload records built by . /// internal sealed record ProgramAccountsConfig { [JsonPropertyName("encoding")] - public required string Encoding { get; init; } + public string? Encoding { get; init; } [JsonPropertyName("commitment")] public Commitment? Commitment { get; init; } @@ -110,6 +139,12 @@ internal sealed record ProgramAccountsConfig [JsonPropertyName("filters")] public object[]? Filters { get; init; } + + [JsonPropertyName("withContext")] + public bool? WithContext { get; init; } + + [JsonPropertyName("sortResults")] + public bool? SortResults { get; init; } } /// The { mint } filter of getTokenAccountsByOwner. @@ -119,6 +154,23 @@ internal sealed record MintFilter public required PublicKey Mint { get; init; } } +/// The { programId } filter of token-account scan methods. +internal sealed record ProgramIdFilter +{ + [JsonPropertyName("programId")] + public required PublicKey ProgramId { get; init; } +} + +/// The requestAirdrop configuration object. +internal sealed record RequestAirdropConfig +{ + [JsonPropertyName("recentBlockhash")] + public string? RecentBlockhash { get; init; } + + [JsonPropertyName("commitment")] + public Commitment? Commitment { get; init; } +} + /// The getTransaction configuration object (base64 or jsonParsed encoding). internal sealed record TransactionConfig { @@ -126,10 +178,10 @@ internal sealed record TransactionConfig public Commitment? Commitment { get; init; } [JsonPropertyName("maxSupportedTransactionVersion")] - public required int MaxSupportedTransactionVersion { get; init; } + public int? MaxSupportedTransactionVersion { get; init; } [JsonPropertyName("encoding")] - public required string Encoding { get; init; } + public string? Encoding { get; init; } } /// The getBlock configuration object ( is unset for the signatures-only read). @@ -139,16 +191,16 @@ internal sealed record BlockConfig public Commitment? Commitment { get; init; } [JsonPropertyName("maxSupportedTransactionVersion")] - public required int MaxSupportedTransactionVersion { get; init; } + public int? MaxSupportedTransactionVersion { get; init; } [JsonPropertyName("encoding")] public string? Encoding { get; init; } [JsonPropertyName("transactionDetails")] - public required string TransactionDetails { get; init; } + public string? TransactionDetails { get; init; } [JsonPropertyName("rewards")] - public required bool Rewards { get; init; } + public bool? Rewards { get; init; } } /// The getSupply configuration object. @@ -176,6 +228,9 @@ internal sealed record InflationRewardConfig [JsonPropertyName("epoch")] public ulong? Epoch { get; init; } + + [JsonPropertyName("minContextSlot")] + public ulong? MinContextSlot { get; init; } } /// The { mentions } filter of logsSubscribe. @@ -185,6 +240,16 @@ internal sealed record LogsFilter public required PublicKey[] Mentions { get; init; } } +/// The signatureSubscribe configuration object. +internal sealed record SignatureSubscribeConfig +{ + [JsonPropertyName("commitment")] + public Commitment? Commitment { get; init; } + + [JsonPropertyName("enableReceivedNotification")] + public bool? EnableReceivedNotification { get; init; } +} + /// The { mentionsAccountOrProgram } filter of blockSubscribe. internal sealed record BlockSubscribeFilter { @@ -200,6 +265,35 @@ internal sealed record LargestAccountsConfig [JsonPropertyName("filter")] public string? Filter { get; init; } + + [JsonPropertyName("sortResults")] + public bool? SortResults { get; init; } +} + +/// The getVoteAccounts configuration object. +internal sealed record VoteAccountsConfig +{ + [JsonPropertyName("votePubkey")] + public PublicKey? VotePublicKey { get; init; } + + [JsonPropertyName("commitment")] + public Commitment? Commitment { get; init; } + + [JsonPropertyName("keepUnstakedDelinquents")] + public bool? KeepUnstakedDelinquents { get; init; } + + [JsonPropertyName("delinquentSlotDistance")] + public ulong? DelinquentSlotDistance { get; init; } +} + +/// The trailing configuration object of getLeaderSchedule. +internal sealed record LeaderScheduleConfig +{ + [JsonPropertyName("identity")] + public PublicKey? Identity { get; init; } + + [JsonPropertyName("commitment")] + public Commitment? Commitment { get; init; } } /// The getBlockProduction configuration object. @@ -232,14 +326,14 @@ internal sealed record BlockSubscribeConfig public Commitment? Commitment { get; init; } [JsonPropertyName("encoding")] - public required string Encoding { get; init; } + public string? Encoding { get; init; } [JsonPropertyName("transactionDetails")] - public required string TransactionDetails { get; init; } + public string? TransactionDetails { get; init; } [JsonPropertyName("showRewards")] - public required bool ShowRewards { get; init; } + public bool? ShowRewards { get; init; } [JsonPropertyName("maxSupportedTransactionVersion")] - public required int MaxSupportedTransactionVersion { get; init; } + public int? MaxSupportedTransactionVersion { get; init; } } diff --git a/src/SolSharp.Rpc/Protocol/RpcRequests.cs b/src/SolSharp.Rpc/Protocol/RpcRequests.cs index ef2d47d..f7a5e37 100644 --- a/src/SolSharp.Rpc/Protocol/RpcRequests.cs +++ b/src/SolSharp.Rpc/Protocol/RpcRequests.cs @@ -10,14 +10,26 @@ namespace SolSharp.Rpc.Protocol; /// internal static class RpcRequests { - public static RpcRequest GetLatestBlockhash(Commitment commitment) => - new() { Method = RpcMethods.GetLatestBlockhash, Params = [new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest GetLatestBlockhash(Commitment? commitment, ulong? minContextSlot = null) => + new() + { + Method = RpcMethods.GetLatestBlockhash, + Params = [new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }] + }; - public static RpcRequest GetBalance(PublicKey account, Commitment commitment) => - new() { Method = RpcMethods.GetBalance, Params = [account, new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest GetBalance(PublicKey account, Commitment? commitment, ulong? minContextSlot = null) => + new() + { + Method = RpcMethods.GetBalance, + Params = [account, new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }] + }; - public static RpcRequest GetSlot(Commitment commitment) => - new() { Method = RpcMethods.GetSlot, Params = [new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest GetSlot(Commitment? commitment, ulong? minContextSlot = null) => + new() + { + Method = RpcMethods.GetSlot, + Params = [new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }] + }; public static RpcRequest GetHealth() => new() { Method = RpcMethods.GetHealth }; @@ -25,11 +37,19 @@ public static RpcRequest GetHealth() => public static RpcRequest GetVersion() => new() { Method = RpcMethods.GetVersion }; - public static RpcRequest GetBlockHeight(Commitment commitment) => - new() { Method = RpcMethods.GetBlockHeight, Params = [new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest GetBlockHeight(Commitment? commitment, ulong? minContextSlot = null) => + new() + { + Method = RpcMethods.GetBlockHeight, + Params = [new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }] + }; - public static RpcRequest GetTransactionCount(Commitment commitment) => - new() { Method = RpcMethods.GetTransactionCount, Params = [new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest GetTransactionCount(Commitment? commitment, ulong? minContextSlot = null) => + new() + { + Method = RpcMethods.GetTransactionCount, + Params = [new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }] + }; public static RpcRequest GetTokenAccountBalance(PublicKey account, Commitment commitment) => new() { Method = RpcMethods.GetTokenAccountBalance, Params = [account, new CommitmentConfig { Commitment = commitment }] }; @@ -68,7 +88,10 @@ public static RpcRequest SimulateTransaction( bool sigVerify, bool replaceRecentBlockhash, Commitment? commitment, - ulong? minContextSlot) => + ulong? minContextSlot, + IReadOnlyList? accounts, + RpcAccountEncoding accountsEncoding, + bool innerInstructions) => new() { Method = RpcMethods.SimulateTransaction, @@ -81,12 +104,25 @@ public static RpcRequest SimulateTransaction( SigVerify = sigVerify, ReplaceRecentBlockhash = replaceRecentBlockhash, Commitment = commitment, - MinContextSlot = minContextSlot + MinContextSlot = minContextSlot, + Accounts = accounts is null + ? null + : new SimulateTransactionAccountsConfig + { + Encoding = RpcWireNames.AccountEncoding(accountsEncoding), + Addresses = [.. accounts] + }, + InnerInstructions = innerInstructions ? true : null } ] }; - public static RpcRequest GetAccountInfo(PublicKey account, Commitment commitment, DataSlice? dataSlice = null) => + public static RpcRequest GetAccountInfo( + PublicKey account, + Commitment? commitment, + DataSlice? dataSlice = null, + ulong? minContextSlot = null, + RpcAccountEncoding? encoding = RpcAccountEncoding.Base64) => new() { Method = RpcMethods.GetAccountInfo, @@ -95,22 +131,34 @@ public static RpcRequest GetAccountInfo(PublicKey account, Commitment commitment account, new AccountInfoConfig { - Encoding = "base64", + Encoding = encoding is { } value ? RpcWireNames.AccountEncoding(value) : null, Commitment = commitment, - DataSlice = dataSlice + DataSlice = dataSlice, + MinContextSlot = minContextSlot } ] }; - public static RpcRequest GetMultipleAccounts(IReadOnlyList accounts, Commitment commitment) + public static RpcRequest GetMultipleAccounts( + IReadOnlyList accounts, + Commitment? commitment, + DataSlice? dataSlice = null, + ulong? minContextSlot = null, + RpcAccountEncoding? encoding = RpcAccountEncoding.Base64) => new() { Method = RpcMethods.GetMultipleAccounts, - Params = [accounts.ToArray(), new AccountInfoConfig - { - Encoding = "base64", - Commitment = commitment - }] + Params = + [ + accounts.ToArray(), + new AccountInfoConfig + { + Encoding = encoding is { } value ? RpcWireNames.AccountEncoding(value) : null, + Commitment = commitment, + DataSlice = dataSlice, + MinContextSlot = minContextSlot + } + ] }; public static RpcRequest GetSignaturesForAddress( @@ -142,7 +190,10 @@ public static RpcRequest GetProgramAccounts( Commitment? commitment, IReadOnlyList? filters, DataSlice? dataSlice, - ulong? minContextSlot) => + ulong? minContextSlot, + bool? withContext, + bool? sortResults, + RpcAccountEncoding? encoding = RpcAccountEncoding.Base64) => new() { Method = RpcMethods.GetProgramAccounts, @@ -151,45 +202,98 @@ public static RpcRequest GetProgramAccounts( programId, new ProgramAccountsConfig { - Encoding = "base64", + Encoding = encoding is { } value ? RpcWireNames.AccountEncoding(value) : null, Commitment = commitment, MinContextSlot = minContextSlot, DataSlice = dataSlice, - Filters = filters?.Select(filter => filter.Payload).ToArray() + Filters = filters?.Select(filter => filter.Payload).ToArray(), + WithContext = withContext, + SortResults = sortResults } ] }; - public static RpcRequest GetEpochInfo(Commitment commitment) => - new() { Method = RpcMethods.GetEpochInfo, Params = [new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest GetEpochInfo(Commitment? commitment, ulong? minContextSlot = null) => + new() + { + Method = RpcMethods.GetEpochInfo, + Params = [new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }] + }; - public static RpcRequest IsBlockhashValid(string blockhash, Commitment commitment) => - new() { Method = RpcMethods.IsBlockhashValid, Params = [blockhash, new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest IsBlockhashValid(string blockhash, Commitment? commitment, ulong? minContextSlot = null) => + new() + { + Method = RpcMethods.IsBlockhashValid, + Params = [blockhash, new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }] + }; - public static RpcRequest GetFeeForMessage(string base64Message, Commitment commitment) => - new() { Method = RpcMethods.GetFeeForMessage, Params = [base64Message, new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest GetFeeForMessage(string base64Message, Commitment? commitment, ulong? minContextSlot = null) => + new() + { + Method = RpcMethods.GetFeeForMessage, + Params = [base64Message, new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }] + }; - public static RpcRequest RequestAirdrop(PublicKey account, ulong lamports, Commitment commitment) => - new() { Method = RpcMethods.RequestAirdrop, Params = [account, lamports, new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest RequestAirdrop( + PublicKey account, + ulong lamports, + Commitment? commitment, + string? recentBlockhash = null) => + new() + { + Method = RpcMethods.RequestAirdrop, + Params = + [ + account, + lamports, + new RequestAirdropConfig { RecentBlockhash = recentBlockhash, Commitment = commitment } + ] + }; - public static RpcRequest GetTokenAccountsByOwner(PublicKey owner, PublicKey mint, Commitment commitment) => + public static RpcRequest GetTokenAccountsByOwner( + PublicKey owner, + TokenAccountsFilter filter, + Commitment? commitment, + DataSlice? dataSlice = null, + ulong? minContextSlot = null, + RpcAccountEncoding? encoding = RpcAccountEncoding.Base64) => new() { Method = RpcMethods.GetTokenAccountsByOwner, - Params = [owner, new MintFilter { Mint = mint }, new AccountInfoConfig { Encoding = "base64", Commitment = commitment }] + Params = + [ + owner, + TokenAccountsFilterPayload(filter), + new AccountInfoConfig + { + Encoding = encoding is { } value ? RpcWireNames.AccountEncoding(value) : null, + Commitment = commitment, + DataSlice = dataSlice, + MinContextSlot = minContextSlot + } + ] }; public static RpcRequest GetRecentPrioritizationFees(IReadOnlyList accounts) => new() { Method = RpcMethods.GetRecentPrioritizationFees, Params = [accounts.ToArray()] }; - public static RpcRequest GetTransaction(string signature, Commitment commitment) => + public static RpcRequest GetTransaction( + string signature, + Commitment? commitment, + byte? maxSupportedTransactionVersion, + RpcTransactionEncoding? encoding = RpcTransactionEncoding.Base64) => new() { Method = RpcMethods.GetTransaction, Params = [ signature, - new TransactionConfig { Commitment = commitment, MaxSupportedTransactionVersion = 0, Encoding = "base64" } + new TransactionConfig + { + Commitment = commitment, + MaxSupportedTransactionVersion = maxSupportedTransactionVersion, + Encoding = encoding is { } value ? RpcWireNames.TransactionEncoding(value) : null + } ] }; @@ -203,17 +307,33 @@ public static RpcRequest GetSignatureStatuses(IReadOnlyList signatures, public static RpcRequest GetSlotLeaders(ulong startSlot, ulong limit) => new() { Method = RpcMethods.GetSlotLeaders, Params = [startSlot, limit] }; - public static RpcRequest GetSupply(Commitment commitment) => + public static RpcRequest GetAgGenesisCert() => + new() { Method = RpcMethods.GetAgGenesisCert }; + + public static RpcRequest GetSupply(Commitment? commitment, bool excludeNonCirculatingAccountsList) => new() { Method = RpcMethods.GetSupply, - Params = [new SupplyConfig { Commitment = commitment, ExcludeNonCirculatingAccountsList = true }] + Params = + [ + new SupplyConfig + { + Commitment = commitment, + ExcludeNonCirculatingAccountsList = excludeNonCirculatingAccountsList + } + ] }; public static RpcRequest GetTokenLargestAccounts(PublicKey mint, Commitment commitment) => new() { Method = RpcMethods.GetTokenLargestAccounts, Params = [mint, new CommitmentConfig { Commitment = commitment }] }; - public static RpcRequest GetBlock(ulong slot, Commitment commitment) => + public static RpcRequest GetBlock( + ulong slot, + Commitment? commitment, + byte? maxSupportedTransactionVersion, + RpcTransactionEncoding? encoding = null, + RpcTransactionDetails? transactionDetails = RpcTransactionDetails.Signatures, + bool? rewards = false) => new() { Method = RpcMethods.GetBlock, @@ -223,25 +343,41 @@ public static RpcRequest GetBlock(ulong slot, Commitment commitment) => new BlockConfig { Commitment = commitment, - MaxSupportedTransactionVersion = 0, - TransactionDetails = "signatures", - Rewards = false + MaxSupportedTransactionVersion = maxSupportedTransactionVersion, + Encoding = encoding is { } encodingValue + ? RpcWireNames.TransactionEncoding(encodingValue) + : null, + TransactionDetails = transactionDetails is { } detailsValue + ? RpcWireNames.TransactionDetails(detailsValue) + : null, + Rewards = rewards } ] }; - public static RpcRequest GetParsedTransaction(string signature, Commitment commitment) => + public static RpcRequest GetParsedTransaction( + string signature, + Commitment commitment, + byte maxSupportedTransactionVersion) => new() { Method = RpcMethods.GetTransaction, Params = [ signature, - new TransactionConfig { Commitment = commitment, MaxSupportedTransactionVersion = 0, Encoding = "jsonParsed" } + new TransactionConfig + { + Commitment = commitment, + MaxSupportedTransactionVersion = maxSupportedTransactionVersion, + Encoding = "jsonParsed" + } ] }; - public static RpcRequest GetParsedBlock(ulong slot, Commitment commitment) => + public static RpcRequest GetParsedBlock( + ulong slot, + Commitment commitment, + byte maxSupportedTransactionVersion) => new() { Method = RpcMethods.GetBlock, @@ -251,7 +387,7 @@ public static RpcRequest GetParsedBlock(ulong slot, Commitment commitment) => new BlockConfig { Commitment = commitment, - MaxSupportedTransactionVersion = 0, + MaxSupportedTransactionVersion = maxSupportedTransactionVersion, Encoding = "jsonParsed", TransactionDetails = "full", Rewards = false @@ -259,32 +395,66 @@ public static RpcRequest GetParsedBlock(ulong slot, Commitment commitment) => ] }; - public static RpcRequest GetVoteAccounts(Commitment commitment) => - new() { Method = RpcMethods.GetVoteAccounts, Params = [new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest GetVoteAccounts( + Commitment? commitment, + PublicKey? votePublicKey = null, + bool? keepUnstakedDelinquents = null, + ulong? delinquentSlotDistance = null) => + new() + { + Method = RpcMethods.GetVoteAccounts, + Params = + [ + new VoteAccountsConfig + { + VotePublicKey = votePublicKey, + Commitment = commitment, + KeepUnstakedDelinquents = keepUnstakedDelinquents, + DelinquentSlotDistance = delinquentSlotDistance + } + ] + }; - public static RpcRequest GetInflationReward(IReadOnlyList addresses, ulong? epoch, Commitment commitment) => + public static RpcRequest GetInflationReward( + IReadOnlyList addresses, + ulong? epoch, + Commitment? commitment, + ulong? minContextSlot = null) => new() { Method = RpcMethods.GetInflationReward, - Params = [addresses.ToArray(), new InflationRewardConfig { Commitment = commitment, Epoch = epoch }] + Params = + [ + addresses.ToArray(), + new InflationRewardConfig + { + Commitment = commitment, + Epoch = epoch, + MinContextSlot = minContextSlot + } + ] }; - public static RpcRequest GetLeaderSchedule(ulong? slot, Commitment commitment) + public static RpcRequest GetLeaderSchedule(ulong? slot, Commitment? commitment, PublicKey? identity = null) { // The slot stays in position 0 even when absent: the node expects a u64-or-null there, so a bare // [config] would be misread as the slot. null! puts a literal JSON null without the nullable warning. object[] parameters = slot is { } s - ? [s, new CommitmentConfig { Commitment = commitment }] - : [null!, new CommitmentConfig { Commitment = commitment }]; + ? [s, new LeaderScheduleConfig { Identity = identity, Commitment = commitment }] + : [null!, new LeaderScheduleConfig { Identity = identity, Commitment = commitment }]; return new RpcRequest { Method = RpcMethods.GetLeaderSchedule, Params = parameters }; } - public static RpcRequest GetBlocks(ulong startSlot, ulong? endSlot, Commitment commitment) + public static RpcRequest GetBlocks( + ulong startSlot, + ulong? endSlot, + Commitment? commitment, + ulong? minContextSlot = null) { object[] parameters = endSlot is { } end - ? [startSlot, end, new CommitmentConfig { Commitment = commitment }] - : [startSlot, new CommitmentConfig { Commitment = commitment }]; + ? [startSlot, end, new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }] + : [startSlot, new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }]; return new RpcRequest { Method = RpcMethods.GetBlocks, Params = parameters }; } @@ -292,11 +462,23 @@ public static RpcRequest GetBlocks(ulong startSlot, ulong? endSlot, Commitment c public static RpcRequest GetClusterNodes() => new() { Method = RpcMethods.GetClusterNodes }; - public static RpcRequest GetParsedAccountInfo(PublicKey account, Commitment commitment) => + public static RpcRequest GetParsedAccountInfo( + PublicKey account, + Commitment? commitment, + ulong? minContextSlot = null) => new() { Method = RpcMethods.GetAccountInfo, - Params = [account, new AccountInfoConfig { Encoding = "jsonParsed", Commitment = commitment }] + Params = + [ + account, + new AccountInfoConfig + { + Encoding = "jsonParsed", + Commitment = commitment, + MinContextSlot = minContextSlot + } + ] }; public static RpcRequest GetBlockCommitment(ulong slot) => @@ -320,11 +502,20 @@ public static RpcRequest GetBlockProduction(Commitment commitment, PublicKey? id public static RpcRequest GetBlockTime(ulong slot) => new() { Method = RpcMethods.GetBlockTime, Params = [slot] }; - public static RpcRequest GetBlocksWithLimit(ulong startSlot, ulong limit, Commitment commitment) => + public static RpcRequest GetBlocksWithLimit( + ulong startSlot, + ulong limit, + Commitment? commitment, + ulong? minContextSlot = null) => new() { Method = RpcMethods.GetBlocksWithLimit, - Params = [startSlot, limit, new CommitmentConfig { Commitment = commitment }] + Params = + [ + startSlot, + limit, + new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot } + ] }; public static RpcRequest GetEpochSchedule() => @@ -348,7 +539,10 @@ public static RpcRequest GetInflationGovernor(Commitment commitment) => public static RpcRequest GetInflationRate() => new() { Method = RpcMethods.GetInflationRate }; - public static RpcRequest GetLargestAccounts(Commitment commitment, LargestAccountsFilter? filter) => + public static RpcRequest GetLargestAccounts( + Commitment? commitment, + LargestAccountsFilter? filter, + bool? sortResults = null) => new() { Method = RpcMethods.GetLargestAccounts, @@ -362,7 +556,8 @@ public static RpcRequest GetLargestAccounts(Commitment commitment, LargestAccoun LargestAccountsFilter.Circulating => "circulating", LargestAccountsFilter.NonCirculating => "nonCirculating", _ => null - } + }, + SortResults = sortResults } ] }; @@ -376,18 +571,54 @@ public static RpcRequest GetMaxShredInsertSlot() => public static RpcRequest GetRecentPerformanceSamples(int? limit) => new() { Method = RpcMethods.GetRecentPerformanceSamples, Params = limit is { } value ? [value] : [] }; - public static RpcRequest GetSlotLeader(Commitment commitment) => - new() { Method = RpcMethods.GetSlotLeader, Params = [new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest GetSlotLeader(Commitment? commitment, ulong? minContextSlot = null) => + new() + { + Method = RpcMethods.GetSlotLeader, + Params = [new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }] + }; - public static RpcRequest GetStakeMinimumDelegation(Commitment commitment) => - new() { Method = RpcMethods.GetStakeMinimumDelegation, Params = [new CommitmentConfig { Commitment = commitment }] }; + public static RpcRequest GetStakeMinimumDelegation(Commitment? commitment, ulong? minContextSlot = null) => + new() + { + Method = RpcMethods.GetStakeMinimumDelegation, + Params = [new ContextConfig { Commitment = commitment, MinContextSlot = minContextSlot }] + }; - public static RpcRequest GetTokenAccountsByDelegate(PublicKey delegateAccount, PublicKey mint, Commitment commitment) => + public static RpcRequest GetTokenAccountsByDelegate( + PublicKey delegateAccount, + TokenAccountsFilter filter, + Commitment? commitment, + DataSlice? dataSlice = null, + ulong? minContextSlot = null, + RpcAccountEncoding? encoding = RpcAccountEncoding.Base64) => new() { Method = RpcMethods.GetTokenAccountsByDelegate, - Params = [delegateAccount, new MintFilter { Mint = mint }, new AccountInfoConfig { Encoding = "base64", Commitment = commitment }] + Params = + [ + delegateAccount, + TokenAccountsFilterPayload(filter), + new AccountInfoConfig + { + Encoding = encoding is { } value ? RpcWireNames.AccountEncoding(value) : null, + Commitment = commitment, + DataSlice = dataSlice, + MinContextSlot = minContextSlot + } + ] + }; + + private static object TokenAccountsFilterPayload(TokenAccountsFilter filter) + { + ArgumentNullException.ThrowIfNull(filter); + return filter.Kind switch + { + TokenAccountsFilterKind.Mint => new MintFilter { Mint = filter.Address }, + TokenAccountsFilterKind.ProgramId => new ProgramIdFilter { ProgramId = filter.Address }, + _ => throw new ArgumentOutOfRangeException(nameof(filter), "Unknown token-account filter kind.") }; + } public static RpcRequest MinimumLedgerSlot() => new() { Method = RpcMethods.MinimumLedgerSlot }; @@ -420,6 +651,7 @@ internal static class RpcMethods public const string GetTransaction = "getTransaction"; public const string GetSignatureStatuses = "getSignatureStatuses"; public const string GetSlotLeaders = "getSlotLeaders"; + public const string GetAgGenesisCert = "getAgGenesisCert"; public const string GetSupply = "getSupply"; public const string GetTokenLargestAccounts = "getTokenLargestAccounts"; public const string GetBlock = "getBlock"; diff --git a/src/SolSharp.Rpc/Protocol/SolanaJsonContext.cs b/src/SolSharp.Rpc/Protocol/SolanaJsonContext.cs index 4b49d4a..d1cacd0 100644 --- a/src/SolSharp.Rpc/Protocol/SolanaJsonContext.cs +++ b/src/SolSharp.Rpc/Protocol/SolanaJsonContext.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; using SolSharp.Core.Primitives; using SolSharp.Rpc.Models; @@ -28,32 +29,46 @@ namespace SolSharp.Rpc.Protocol; [JsonSerializable(typeof(string[]))] // Request configuration objects (see RpcParams.cs and AccountFilter.cs). [JsonSerializable(typeof(CommitmentConfig))] +[JsonSerializable(typeof(ContextConfig))] [JsonSerializable(typeof(SendTransactionConfig))] [JsonSerializable(typeof(SimulateTransactionConfig))] +[JsonSerializable(typeof(SimulateTransactionAccountsConfig))] [JsonSerializable(typeof(AccountInfoConfig))] [JsonSerializable(typeof(SignaturesForAddressConfig))] [JsonSerializable(typeof(ProgramAccountsConfig))] [JsonSerializable(typeof(MintFilter))] +[JsonSerializable(typeof(ProgramIdFilter))] +[JsonSerializable(typeof(RequestAirdropConfig))] [JsonSerializable(typeof(TransactionConfig))] [JsonSerializable(typeof(BlockConfig))] [JsonSerializable(typeof(SupplyConfig))] [JsonSerializable(typeof(SignatureStatusesConfig))] [JsonSerializable(typeof(InflationRewardConfig))] [JsonSerializable(typeof(LogsFilter))] +[JsonSerializable(typeof(SignatureSubscribeConfig))] [JsonSerializable(typeof(BlockSubscribeFilter))] [JsonSerializable(typeof(BlockSubscribeConfig))] [JsonSerializable(typeof(MemcmpFilter))] +[JsonSerializable(typeof(RawMemcmpFilter))] [JsonSerializable(typeof(DataSizeFilter))] [JsonSerializable(typeof(LargestAccountsConfig))] +[JsonSerializable(typeof(VoteAccountsConfig))] +[JsonSerializable(typeof(LeaderScheduleConfig))] [JsonSerializable(typeof(BlockProductionConfig))] // HTTP responses - the JSON-RPC error object plus every result shape requested by the client // (the envelope itself is walked with a Utf8JsonReader in SolanaRpcClient, not deserialized). [JsonSerializable(typeof(RpcError))] +[JsonSerializable(typeof(RpcAccountData))] +[JsonSerializable(typeof(RpcAccountInfo))] +[JsonSerializable(typeof(RpcProgramAccount))] [JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] +[JsonSerializable(typeof(RpcContextValue))] +[JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] +[JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] @@ -61,14 +76,18 @@ namespace SolSharp.Rpc.Protocol; [JsonSerializable(typeof(EpochInfo))] [JsonSerializable(typeof(SignatureInfo[]))] [JsonSerializable(typeof(ProgramAccount[]))] +[JsonSerializable(typeof(RpcProgramAccount[]))] [JsonSerializable(typeof(PrioritizationFee[]))] +[JsonSerializable(typeof(ClusterNode[]))] +[JsonSerializable(typeof(RpcTransactionVersion))] [JsonSerializable(typeof(TransactionResponse))] [JsonSerializable(typeof(Block))] +[JsonSerializable(typeof(JsonElement?))] [JsonSerializable(typeof(ParsedTransaction))] [JsonSerializable(typeof(ParsedBlock))] [JsonSerializable(typeof(VoteAccounts))] [JsonSerializable(typeof(IReadOnlyList))] -[JsonSerializable(typeof(IReadOnlyDictionary>))] +[JsonSerializable(typeof(IReadOnlyDictionary>))] [JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(BlockCommitment))] @@ -81,6 +100,7 @@ namespace SolSharp.Rpc.Protocol; [JsonSerializable(typeof(InflationRate))] [JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(PerformanceSample[]))] +[JsonSerializable(typeof(AgGenesisCertificate))] // WebSocket notification payloads (SubscriptionSink roots). [JsonSerializable(typeof(SlotInfo))] [JsonSerializable(typeof(VoteNotification))] @@ -89,8 +109,11 @@ namespace SolSharp.Rpc.Protocol; [JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] +[JsonSerializable(typeof(RpcContextValue))] +[JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] +[JsonSerializable(typeof(RpcContextValue))] [JsonSerializable(typeof(RpcContextValue))] // Batch result values (RpcBatch map delegates deserialize these directly). [JsonSerializable(typeof(RpcContextValue))] @@ -99,8 +122,10 @@ namespace SolSharp.Rpc.Protocol; // Types reached only from inside hand-written converters: a [JsonConverter]-attributed type is opaque to // the generator's graph walk, so what its converter deserializes must be registered explicitly. [JsonSerializable(typeof(ParsedMessage))] +[JsonSerializable(typeof(ParsedTransactionConfig))] [JsonSerializable(typeof(ParsedTransactionMeta))] [JsonSerializable(typeof(ParsedInstructionInfo))] +[JsonSerializable(typeof(IReadOnlyList))] [JsonSerializable(typeof(IReadOnlyList))] [JsonSourceGenerationOptions( GenerationMode = JsonSourceGenerationMode.Metadata, diff --git a/src/SolSharp.Rpc/RpcAccountReadOptions.cs b/src/SolSharp.Rpc/RpcAccountReadOptions.cs new file mode 100644 index 0000000..a163b97 --- /dev/null +++ b/src/SolSharp.Rpc/RpcAccountReadOptions.cs @@ -0,0 +1,74 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc; + +/// The account-data encoding accepted by Solana account RPC methods. +public enum RpcAccountEncoding +{ + /// The legacy bare base58 string response. + Binary, + + /// A base58 string paired with the base58 encoding tag. + Base58, + + /// A base64 string paired with the base64 encoding tag. + Base64, + + /// + /// A node-parsed object when the account owner is recognized, with a base64 tuple fallback otherwise. + /// + JsonParsed, + + /// A zstd-compressed byte sequence encoded as base64 and tagged base64+zstd. + Base64Zstd +} + +/// +/// Exact upstream account configuration for getAccountInfo, getMultipleAccounts, and +/// token-account scans. Unset fields use the node defaults. +/// +public sealed record RpcAccountInfoOptions +{ + /// The account-data encoding; use the method-specific node default when null. + public RpcAccountEncoding? Encoding { get; init; } + + /// The commitment level to query at. + public Commitment? Commitment { get; init; } + + /// Return only this slice of the account data; return all data when null. + public DataSlice? DataSlice { get; init; } + + /// The minimum slot at which the request may be evaluated. + public ulong? MinContextSlot { get; init; } +} + +/// +/// Exact upstream getProgramAccounts configuration. The response data remains typed even though +/// changes the account-data branch. +/// +public sealed record RpcProgramAccountsOptions +{ + /// The account-data encoding; use the node default when null. + public RpcAccountEncoding? Encoding { get; init; } + + /// The commitment level to query at. + public Commitment? Commitment { get; init; } + + /// Filters every returned account must satisfy; apply none when null. + public IReadOnlyList? Filters { get; init; } + + /// Return only this slice of each account's data; return all data when null. + public DataSlice? DataSlice { get; init; } + + /// The minimum slot at which the request may be evaluated. + public ulong? MinContextSlot { get; init; } + + /// + /// Request the upstream { context, value } response shape when true. The list-returning + /// client method unwraps its value component. + /// + public bool? WithContext { get; init; } + + /// Whether the node sorts accounts by public key; use the node default when null. + public bool? SortResults { get; init; } +} diff --git a/src/SolSharp.Rpc/RpcBatch.cs b/src/SolSharp.Rpc/RpcBatch.cs index 2578ea9..19b8c14 100644 --- a/src/SolSharp.Rpc/RpcBatch.cs +++ b/src/SolSharp.Rpc/RpcBatch.cs @@ -29,29 +29,33 @@ public sealed class RpcBatch /// The commitment level to query at. /// The balance in lamports, once the batch executes. public Task GetBalanceAsync(PublicKey account, Commitment commitment = Commitment.Confirmed) - => Add(RpcRequests.GetBalance(account, commitment), - static result => result.Deserialize(RpcJson.TypeInfo>())!.Value); + => Add( + RpcRequests.GetBalance(account, commitment), + static result => RequireContextValue(result)); /// Queues a getAccountInfo call (base64 account data). /// The account to query. /// The commitment level to query at. /// The account, or null if it does not exist, once the batch executes. public Task GetAccountInfoAsync(PublicKey account, Commitment commitment = Commitment.Confirmed) - => Add(RpcRequests.GetAccountInfo(account, commitment), - static result => result.Deserialize(RpcJson.TypeInfo>())!.Value); + => Add( + RpcRequests.GetAccountInfo(account, commitment), + static result => DeserializeContext(result).Value); /// Queues a getLatestBlockhash call. /// The commitment level to query at. /// The blockhash and its last valid block height, once the batch executes. public Task GetLatestBlockhashAsync(Commitment commitment = Commitment.Confirmed) - => Add(RpcRequests.GetLatestBlockhash(commitment), - static result => result.Deserialize(RpcJson.TypeInfo>())!.Value!); + => Add( + RpcRequests.GetLatestBlockhash(commitment), + static result => RequireContextValue(result)); /// Queues a getSlot call. /// The commitment level to query at. /// The current slot, once the batch executes. public Task GetSlotAsync(Commitment commitment = Commitment.Confirmed) - => Add(RpcRequests.GetSlot(commitment), + => Add( + RpcRequests.GetSlot(commitment), static result => result.GetUInt64()); /// Queues a getTokenAccountBalance call. @@ -59,12 +63,13 @@ public Task GetSlotAsync(Commitment commitment = Commitment.Confirmed) /// The commitment level to query at. /// The token balance, once the batch executes. public Task GetTokenAccountBalanceAsync(PublicKey tokenAccount, Commitment commitment = Commitment.Confirmed) - => Add(RpcRequests.GetTokenAccountBalance(tokenAccount, commitment), - static result => result.Deserialize(RpcJson.TypeInfo>())!.Value!); + => Add( + RpcRequests.GetTokenAccountBalance(tokenAccount, commitment), + static result => RequireContextValue(result)); /// Queues a sendTransaction call - e.g. to submit several signed transactions in one round-trip. /// The signed transaction's serialized wire bytes. - /// Send options; node defaults are used when null. + /// Send options; client defaults are used when null. /// The transaction signature (base58), once the batch executes. /// is null. public Task SendTransactionAsync(byte[] transaction, SendTransactionOptions? options = null) @@ -75,7 +80,9 @@ public Task SendTransactionAsync(byte[] transaction, SendTransactionOpti var encoded = Convert.ToBase64String(transaction); return Add( RpcRequests.SendTransaction(encoded, options.SkipPreflight, options.PreflightCommitment, options.MaxRetries, options.MinContextSlot), - static result => result.GetString()!); + static result => result.ValueKind == JsonValueKind.String + ? result.GetString()! + : throw new JsonException("sendTransaction returned a non-string result.")); } /// Submits every queued call as one JSON-RPC batch and completes their tasks. @@ -94,13 +101,61 @@ public async Task ExecuteAsync(CancellationToken cancellationToken = default) _executed = true; - JsonElement root; try { - root = await _client.SendBatchAsync(_requests, cancellationToken); + var root = await _client.SendBatchAsync(_requests, cancellationToken); if (root.ValueKind != JsonValueKind.Array) throw new RpcException(-1, $"Expected a JSON-RPC batch response array, got {root.ValueKind}."); + + // Responses may arrive in any order. Validate the complete envelope before resolving calls so + // malformed, duplicate, or injected ids cannot leave some TaskCompletionSources pending forever. + var expectedIds = _pending.Select(static pending => pending.Id).ToHashSet(); + var responses = new Dictionary(_pending.Count); + foreach (var element in root.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.Object) + throw new RpcException(-1, $"Expected each JSON-RPC batch entry to be an object, got {element.ValueKind}."); + + if (!element.TryGetProperty("jsonrpc", out var version) || + version.ValueKind != JsonValueKind.String || + version.GetString() != "2.0") + throw new RpcException(-1, "A batch response entry carried an invalid JSON-RPC version."); + + if (!element.TryGetProperty("id", out var id) || + id.ValueKind != JsonValueKind.Number || + !id.TryGetInt32(out var value)) + throw new RpcException(-1, "A batch response entry carried no valid integer id."); + if (!expectedIds.Contains(value)) + throw new RpcException(-1, $"The batch response contained unknown request id {value}."); + if (!responses.TryAdd(value, element)) + throw new RpcException(-1, $"The batch response contained duplicate request id {value}."); + + var hasResult = element.TryGetProperty("result", out _); + var hasError = element.TryGetProperty("error", out var error) && + error.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined); + if (hasResult == hasError) + throw new RpcException( + -1, + $"The batch response entry for request {value} must carry exactly one of result or error."); + if (hasError && + (error.ValueKind != JsonValueKind.Object || + !error.TryGetProperty("code", out var code) || + code.ValueKind != JsonValueKind.Number || + !code.TryGetInt32(out _) || + !error.TryGetProperty("message", out var message) || message.ValueKind != JsonValueKind.String)) + throw new RpcException( + -1, + $"The batch response entry for request {value} carried a malformed error object."); + } + + foreach (var pending in _pending) + { + if (responses.TryGetValue(pending.Id, out var response)) + pending.Complete(response); + else + pending.Fail(new RpcException(-1, $"The batch response contained no entry for request {pending.Id}.")); + } } catch (Exception exception) { @@ -108,20 +163,6 @@ public async Task ExecuteAsync(CancellationToken cancellationToken = default) pending.Fail(exception); throw; } - - // Responses may arrive in any order; match them to the queued calls by id. - var responses = new Dictionary(_pending.Count); - foreach (var element in root.EnumerateArray()) - if (element.TryGetProperty("id", out var id) && id.TryGetInt32(out var value)) - responses[value] = element; - - foreach (var pending in _pending) - { - if (responses.TryGetValue(pending.Id, out var response)) - pending.Complete(response); - else - pending.Fail(new RpcException(-1, $"The batch response contained no entry for request {pending.Id}.")); - } } private Task Add(RpcRequest request, Func map) @@ -135,6 +176,19 @@ private Task Add(RpcRequest request, Func map) return pending.Source.Task; } + private static RpcContextValue DeserializeContext(JsonElement result) + => result.Deserialize(RpcJson.TypeInfo>()) + ?? throw new JsonException("A batched RPC method returned a null context wrapper."); + + private static T RequireContextValue(JsonElement result) + { + var context = DeserializeContext(result); + if (context.Value is null) + throw new JsonException("A batched RPC context wrapper carried null for a non-null value contract."); + + return context.Value; + } + private interface IPending { int Id { get; } @@ -152,25 +206,28 @@ private sealed class Pending(int id, Func map) : IPending public void Complete(JsonElement response) { - if (response.TryGetProperty("error", out var error) && error.ValueKind is not JsonValueKind.Null) - { - var code = error.ValueKind == JsonValueKind.Object && - error.TryGetProperty("code", out var codeElement) && - codeElement.TryGetInt32(out var codeValue) - ? codeValue - : -1; - var message = error.ValueKind == JsonValueKind.Object && - error.TryGetProperty("message", out var messageElement) && - messageElement.ValueKind == JsonValueKind.String - ? messageElement.GetString()! - : error.GetRawText(); - - Source.TrySetException(new RpcException(code, message)); - return; - } - try { + if (response.TryGetProperty("error", out var error) && error.ValueKind is not JsonValueKind.Null) + { + var code = error.ValueKind == JsonValueKind.Object && + error.TryGetProperty("code", out var codeElement) && + codeElement.TryGetInt32(out var codeValue) + ? codeValue + : -1; + var message = error.ValueKind == JsonValueKind.Object && + error.TryGetProperty("message", out var messageElement) && + messageElement.ValueKind == JsonValueKind.String + ? messageElement.GetString()! + : error.GetRawText(); + var data = error.ValueKind == JsonValueKind.Object && error.TryGetProperty("data", out var dataElement) + ? dataElement + : (JsonElement?)null; + + Source.TrySetException(new RpcException(code, message, data)); + return; + } + if (!response.TryGetProperty("result", out var result)) throw new RpcException(-1, $"The batch response entry for request {Id} carried neither a result nor an error."); diff --git a/src/SolSharp.Rpc/RpcContextOptions.cs b/src/SolSharp.Rpc/RpcContextOptions.cs new file mode 100644 index 0000000..cc65938 --- /dev/null +++ b/src/SolSharp.Rpc/RpcContextOptions.cs @@ -0,0 +1,16 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc; + +/// +/// Commitment and minimum-context-slot options shared by RPC methods backed by Agave's +/// RpcContextConfig. Unset fields use the node defaults. +/// +public sealed record RpcContextOptions +{ + /// The commitment level to query at. + public Commitment? Commitment { get; init; } + + /// The minimum slot at which the request may be evaluated. + public ulong? MinContextSlot { get; init; } +} diff --git a/src/SolSharp.Rpc/RpcReadOptions.cs b/src/SolSharp.Rpc/RpcReadOptions.cs new file mode 100644 index 0000000..aedad45 --- /dev/null +++ b/src/SolSharp.Rpc/RpcReadOptions.cs @@ -0,0 +1,201 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc; + +/// Options for . +public sealed record RequestAirdropOptions +{ + /// The recent blockhash the faucet transaction must use; the node chooses one when null. + public string? RecentBlockhash { get; init; } + + /// The commitment level used to select the bank that creates the faucet transaction. + public Commitment? Commitment { get; init; } +} + +/// Options for . +public sealed record GetVoteAccountsOptions +{ + /// Return only the validator with this vote-account address; return all validators when null. + public PublicKey? VotePublicKey { get; init; } + + /// The commitment level to query at. + public Commitment? Commitment { get; init; } + + /// Whether delinquent validators with no active stake remain in the response. + public bool? KeepUnstakedDelinquents { get; init; } + + /// The slot distance after which a validator is considered delinquent. + public ulong? DelinquentSlotDistance { get; init; } +} + +/// Options for . +public sealed record GetLeaderScheduleOptions +{ + /// A slot in the epoch to query; the current epoch when null. + public ulong? Slot { get; init; } + + /// Return only this validator identity; return all identities when null. + public PublicKey? Identity { get; init; } + + /// The commitment level to query at. + public Commitment? Commitment { get; init; } +} + +/// Options for . +public sealed record GetLargestAccountsOptions +{ + /// The commitment level to query at. + public Commitment? Commitment { get; init; } + + /// Restrict results to circulating or non-circulating accounts; include both when null. + public LargestAccountsFilter? Filter { get; init; } + + /// Whether the node sorts the result by balance; use the node default when null. + public bool? SortResults { get; init; } +} + +/// Options for . +public sealed record GetSupplyOptions +{ + /// The commitment level to query at. + public Commitment? Commitment { get; init; } + + /// + /// Omits the potentially large non-circulating account-address list when true. + /// The upstream default is false. + /// + public bool ExcludeNonCirculatingAccountsList { get; init; } +} + +/// Options for . +public sealed record GetInflationRewardOptions +{ + /// The epoch to query; the previous epoch when null. + public ulong? Epoch { get; init; } + + /// The commitment level to query at. + public Commitment? Commitment { get; init; } + + /// The minimum slot at which the request may be evaluated. + public ulong? MinContextSlot { get; init; } +} + +/// The transaction encoding requested from getBlock, getTransaction, or blockSubscribe. +public enum RpcTransactionEncoding +{ + /// The legacy binary alias for base58 encoding. + Binary, + + /// Base64-encoded transaction wire bytes. + Base64, + + /// Base58-encoded transaction wire bytes. + Base58, + + /// JSON transaction objects with compiled instructions. + Json, + + /// JSON transaction objects with recognized instructions parsed by the node. + JsonParsed +} + +/// The amount of transaction information requested for a block. +public enum RpcTransactionDetails +{ + /// Full transactions and execution metadata. + Full, + + /// Transaction signatures only. + Signatures, + + /// No transaction data. + None, + + /// Transaction signatures and account-key metadata without instructions. + Accounts +} + +/// +/// Exact upstream getBlock configuration. The configured response shape depends on +/// and and is therefore returned as JSON. +/// +public sealed record GetBlockOptions +{ + /// The transaction encoding; use the node default when null. + public RpcTransactionEncoding? Encoding { get; init; } + + /// The transaction detail level; use the node default when null. + public RpcTransactionDetails? TransactionDetails { get; init; } + + /// Whether block-level rewards are included; use the node default when null. + public bool? Rewards { get; init; } + + /// The commitment level to query at. + public Commitment? Commitment { get; init; } + + /// The highest numeric transaction version the caller accepts. + public byte? MaxSupportedTransactionVersion { get; init; } +} + +/// +/// Exact upstream getTransaction configuration. The configured response shape depends on +/// and is therefore returned as JSON. +/// +public sealed record GetTransactionOptions +{ + /// The transaction encoding; use the node default when null. + public RpcTransactionEncoding? Encoding { get; init; } + + /// The commitment level to query at. + public Commitment? Commitment { get; init; } + + /// The highest numeric transaction version the caller accepts. + public byte? MaxSupportedTransactionVersion { get; init; } +} + +internal static class RpcWireNames +{ + public static string AccountEncoding(RpcAccountEncoding value) => value switch + { + RpcAccountEncoding.Binary => "binary", + RpcAccountEncoding.Base58 => "base58", + RpcAccountEncoding.Base64 => "base64", + RpcAccountEncoding.JsonParsed => "jsonParsed", + RpcAccountEncoding.Base64Zstd => "base64+zstd", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown account encoding.") + }; + + public static bool TryAccountEncoding(string? value, out RpcAccountEncoding encoding) + { + encoding = value switch + { + "binary" => RpcAccountEncoding.Binary, + "base58" => RpcAccountEncoding.Base58, + "base64" => RpcAccountEncoding.Base64, + "jsonParsed" => RpcAccountEncoding.JsonParsed, + "base64+zstd" => RpcAccountEncoding.Base64Zstd, + _ => default + }; + + return value is "binary" or "base58" or "base64" or "jsonParsed" or "base64+zstd"; + } + + public static string TransactionEncoding(RpcTransactionEncoding value) => value switch + { + RpcTransactionEncoding.Binary => "binary", + RpcTransactionEncoding.Base64 => "base64", + RpcTransactionEncoding.Base58 => "base58", + RpcTransactionEncoding.Json => "json", + RpcTransactionEncoding.JsonParsed => "jsonParsed", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown transaction encoding.") + }; + + public static string TransactionDetails(RpcTransactionDetails value) => value switch + { + RpcTransactionDetails.Full => "full", + RpcTransactionDetails.Signatures => "signatures", + RpcTransactionDetails.None => "none", + RpcTransactionDetails.Accounts => "accounts", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown transaction detail level.") + }; +} diff --git a/src/SolSharp.Rpc/SendTransactionOptions.cs b/src/SolSharp.Rpc/SendTransactionOptions.cs index f10c5a5..ff0a20b 100644 --- a/src/SolSharp.Rpc/SendTransactionOptions.cs +++ b/src/SolSharp.Rpc/SendTransactionOptions.cs @@ -10,7 +10,7 @@ public sealed record SendTransactionOptions /// /// The commitment preflight runs at. Defaults to to match - /// : the node's own default is finalized, + /// : the node's own default is finalized, /// where a blockhash fetched at confirmed may not exist yet, so preflight would report /// BlockhashNotFound for a perfectly valid transaction. Set to null for the node default. /// diff --git a/src/SolSharp.Rpc/ServiceCollectionExtensions.cs b/src/SolSharp.Rpc/ServiceCollectionExtensions.cs index 558c992..9d506c4 100644 --- a/src/SolSharp.Rpc/ServiceCollectionExtensions.cs +++ b/src/SolSharp.Rpc/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Polly; using SolSharp.Rpc.Streaming; namespace SolSharp.Rpc; @@ -35,8 +36,8 @@ public static IHttpClientBuilder AddSolanaRpc( Action? configureResilience = null) { // Validated with an explicit predicate rather than ValidateDataAnnotations: the DataAnnotations - // validator reflects over the options type (RequiresUnreferencedCode, not AOT-safe), and this - // single check subsumes [Required] and [Url] for the one property anyway. + // validator reflects over the options type (RequiresUnreferencedCode, not AOT-safe), so keep the + // endpoint and response-limit checks as explicit predicates. services .AddOptions() .Configure(configure) @@ -44,6 +45,9 @@ public static IHttpClientBuilder AddSolanaRpc( options => Uri.TryCreate(options.Endpoint, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https", "SolanaRpcOptions.Endpoint must be an absolute http(s) URL.") + .Validate( + options => options.MaximumResponseContentLength > 0, + "SolanaRpcOptions.MaximumResponseContentLength must be positive.") .ValidateOnStart(); var builder = services.AddHttpClient((provider, client) => @@ -55,6 +59,17 @@ public static IHttpClientBuilder AddSolanaRpc( var resilience = builder.AddStandardResilienceHandler(); if (configureResilience is not null) resilience.Configure(configureResilience); + resilience.Configure(options => + { + var shouldHandle = options.Retry.ShouldHandle; + options.Retry.ShouldHandle = arguments => + { + var request = arguments.Outcome.Result?.RequestMessage ?? arguments.Context.GetRequestMessage(); + return request?.Options.TryGetValue(SolanaRpcClient.DisableRetriesKey, out var disabled) == true && disabled + ? new ValueTask(false) + : shouldHandle(arguments); + }; + }); return builder; } diff --git a/src/SolSharp.Rpc/SimulateTransactionOptions.cs b/src/SolSharp.Rpc/SimulateTransactionOptions.cs index d8b1080..cfbf6f9 100644 --- a/src/SolSharp.Rpc/SimulateTransactionOptions.cs +++ b/src/SolSharp.Rpc/SimulateTransactionOptions.cs @@ -13,7 +13,7 @@ public sealed record SimulateTransactionOptions /// /// The commitment the simulation runs at. Defaults to - /// to match : at the node default of + /// to match : at the node default of /// finalized a blockhash fetched at confirmed may not exist yet, failing the simulation /// with BlockhashNotFound. Set to null for the node default. /// @@ -21,4 +21,20 @@ public sealed record SimulateTransactionOptions /// The minimum slot at which the request may be evaluated. public ulong? MinContextSlot { get; init; } + + /// + /// Accounts whose post-simulation state the node should return. The node limits the number of requested + /// addresses to the transaction's account count; no states are requested when null. + /// + public IReadOnlyList? Accounts { get; init; } + + /// + /// Encoding for requested post-simulation accounts. Agave supports , + /// , and on this path. + /// Defaults to base64. + /// + public RpcAccountEncoding AccountsEncoding { get; init; } = RpcAccountEncoding.Base64; + + /// Requests parsed inner instructions from the simulation. Default false. + public bool InnerInstructions { get; init; } } diff --git a/src/SolSharp.Rpc/SolSharp.Rpc.csproj b/src/SolSharp.Rpc/SolSharp.Rpc.csproj index a6bed24..dfcfa46 100644 --- a/src/SolSharp.Rpc/SolSharp.Rpc.csproj +++ b/src/SolSharp.Rpc/SolSharp.Rpc.csproj @@ -3,13 +3,13 @@ net8.0 true - Solana JSON-RPC client for .NET: typed HTTP reads, multiplexed WebSocket subscriptions, and dependency-injection registration with a built-in resilience pipeline. + Typed Solana JSON-RPC and PubSub for .NET: bounded HTTP reads/send/simulate/batching, multiplexed reconnecting WebSocket subscriptions, source-generated JSON, and resilient DI registration. - + diff --git a/src/SolSharp.Rpc/SolanaRpcClient.cs b/src/SolSharp.Rpc/SolanaRpcClient.cs index 486f91e..e54d724 100644 --- a/src/SolSharp.Rpc/SolanaRpcClient.cs +++ b/src/SolSharp.Rpc/SolanaRpcClient.cs @@ -1,5 +1,9 @@ +using System.Buffers; using System.Net.Http.Json; using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using SolSharp.Core.Constants; using SolSharp.Core.Primitives; using SolSharp.Rpc.Models; using SolSharp.Rpc.Models.Parsed; @@ -8,12 +12,54 @@ namespace SolSharp.Rpc; /// -/// Minimal Solana JSON-RPC client over HTTP. The supplied must have its -/// BaseAddress set to the RPC endpoint. Read methods only for now; throws -/// on a node-level error. +/// Solana JSON-RPC client over HTTP for chain reads, transaction submission and simulation, confirmation, +/// and supported node operations. The supplied must have its BaseAddress set to +/// the RPC endpoint. Node-level errors are surfaced as . /// -public class SolanaRpcClient(HttpClient httpClient) +public class SolanaRpcClient { + internal static readonly HttpRequestOptionsKey DisableRetriesKey = new("SolSharp.DisableRetries"); + + private static readonly PublicKey SystemProgramOwner = PublicKey.Parse(SolanaProgramIds.SystemProgram); + private static readonly PublicKey TokenProgramOwner = PublicKey.Parse(SolanaProgramIds.TokenProgram); + private static readonly PublicKey Token2022ProgramOwner = PublicKey.Parse(SolanaProgramIds.Token2022Program); + private static readonly PublicKey AddressLookupTableProgramOwner = PublicKey.Parse(SolanaProgramIds.AddressLookupTableProgram); + + private readonly HttpClient _httpClient; + private readonly int _maximumResponseContentLength; + + /// Creates a client with the default 128 MiB HTTP response-body limit. + /// The HTTP client whose base address points at a Solana JSON-RPC endpoint. + public SolanaRpcClient(HttpClient httpClient) + : this(httpClient, SolanaRpcOptions.DefaultMaximumResponseContentLength) + { + } + + /// Creates a client with an explicit HTTP response-body limit. + /// The HTTP client whose base address points at a Solana JSON-RPC endpoint. + /// Maximum decoded response body size, in bytes. + /// is null. + /// is not positive. + public SolanaRpcClient(HttpClient httpClient, int maximumResponseContentLength) + { + ArgumentNullException.ThrowIfNull(httpClient); + if (maximumResponseContentLength <= 0) + throw new ArgumentOutOfRangeException( + nameof(maximumResponseContentLength), maximumResponseContentLength, "The response-content limit must be positive."); + + _httpClient = httpClient; + _maximumResponseContentLength = maximumResponseContentLength; + } + + /// Creates a client from dependency-injection options. + /// The HTTP client whose base address points at a Solana JSON-RPC endpoint. + /// The configured RPC options. + [ActivatorUtilitiesConstructor] + public SolanaRpcClient(HttpClient httpClient, IOptions options) + : this(httpClient, (options ?? throw new ArgumentNullException(nameof(options))).Value.MaximumResponseContentLength) + { + } + /// /// Returns the latest blockhash, used as the recent blockhash when building a transaction. /// See getLatestBlockhash. @@ -28,10 +74,30 @@ public async Task GetLatestBlockhashAsync( Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) { - var result = await SendAsync>(RpcRequests - .GetLatestBlockhash(commitment), cancellationToken); + var result = await SendAsync>( + RpcRequests.GetLatestBlockhash(commitment), + cancellationToken); - return result.Value!; + return RequireContextValue(result); + } + + /// Returns the latest blockhash with explicit commitment and minimum-context-slot options. + /// The context options sent to the node. + /// A token to cancel the request. + /// The blockhash and the last block height at which it stays valid. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task GetLatestBlockhashWithOptionsAsync( + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var result = await SendAsync>( + RpcRequests.GetLatestBlockhash(options.Commitment, options.MinContextSlot), cancellationToken); + + return RequireContextValue(result); } /// @@ -50,8 +116,30 @@ public async Task GetBalanceAsync( Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) { - var result = await SendAsync>(RpcRequests - .GetBalance(account, commitment), cancellationToken); + var result = await SendAsync>( + RpcRequests.GetBalance(account, commitment), + cancellationToken); + + return result.Value; + } + + /// Returns an account balance with explicit commitment and minimum-context-slot options. + /// The account to query. + /// The context options sent to the node. + /// A token to cancel the request. + /// The balance in lamports. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task GetBalanceWithOptionsAsync( + PublicKey account, + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var result = await SendAsync>( + RpcRequests.GetBalance(account, options.Commitment, options.MinContextSlot), cancellationToken); return result.Value; } @@ -71,6 +159,22 @@ public Task GetSlotAsync( CancellationToken cancellationToken = default) => SendAsync(RpcRequests.GetSlot(commitment), cancellationToken); + /// Returns the current slot with explicit commitment and minimum-context-slot options. + /// The context options sent to the node. + /// A token to cancel the request. + /// The current slot. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task GetSlotWithOptionsAsync( + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync(RpcRequests.GetSlot(options.Commitment, options.MinContextSlot), cancellationToken); + } + /// /// Returns whether the node reports itself healthy ("ok"). /// See getHealth. @@ -113,6 +217,23 @@ public Task GetBlockHeightAsync( CancellationToken cancellationToken = default) => SendAsync(RpcRequests.GetBlockHeight(commitment), cancellationToken); + /// Returns the current block height with explicit commitment and minimum-context-slot options. + /// The context options sent to the node. + /// A token to cancel the request. + /// The current block height. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task GetBlockHeightWithOptionsAsync( + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync( + RpcRequests.GetBlockHeight(options.Commitment, options.MinContextSlot), cancellationToken); + } + /// /// Returns the number of transactions the cluster has processed. /// See getTransactionCount. @@ -128,6 +249,23 @@ public Task GetTransactionCountAsync( CancellationToken cancellationToken = default) => SendAsync(RpcRequests.GetTransactionCount(commitment), cancellationToken); + /// Returns the transaction count with explicit commitment and minimum-context-slot options. + /// The context options sent to the node. + /// A token to cancel the request. + /// The total transaction count. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task GetTransactionCountWithOptionsAsync( + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync( + RpcRequests.GetTransactionCount(options.Commitment, options.MinContextSlot), cancellationToken); + } + /// /// Returns the token balance of an SPL token account. /// See getTokenAccountBalance. @@ -147,7 +285,7 @@ public async Task GetTokenAccountBalanceAsync( var result = await SendAsync>( RpcRequests.GetTokenAccountBalance(account, commitment), cancellationToken); - return result.Value!; + return RequireContextValue(result); } /// @@ -169,7 +307,7 @@ public async Task GetTokenSupplyAsync( var result = await SendAsync>( RpcRequests.GetTokenSupply(mint, commitment), cancellationToken); - return result.Value!; + return RequireContextValue(result); } /// @@ -194,7 +332,7 @@ public Task GetMinimumBalanceForRentExemptionAsync( /// See sendTransaction. /// /// The signed transaction's serialized wire bytes; base64-encoded for the request. - /// Send options (skip preflight, retries, commitment); node defaults are used when null. + /// Send options (skip preflight, retries, commitment); client defaults are used when null. /// A token to cancel the request. /// The transaction signature (base58). /// is null. @@ -216,14 +354,20 @@ public Task SendTransactionAsync( } /// - /// Simulates a transaction without submitting it, returning its logs, compute units, and any error. + /// Simulates a transaction without submitting it, returning its error, logs, resource usage, optional + /// account states, inner instructions, balances, fee, loaded addresses, blockhash replacement, and return data. /// See simulateTransaction. /// /// The transaction's serialized wire bytes; base64-encoded for the request. - /// Simulation options (signature verification, blockhash replacement, commitment); node defaults are used when null. + /// + /// Simulation options including signature verification, blockhash replacement, commitment, requested + /// post-simulation accounts and their encoding, and parsed inner instructions; client defaults are used when null. + /// /// A token to cancel the request. /// The simulation result. /// is null. + /// and are both enabled. + /// Post-simulation accounts were requested and is unsupported by Agave's simulation account path. /// The node returned a JSON-RPC error. /// The request failed at the transport level or returned a non-success status. /// The was cancelled. @@ -234,13 +378,32 @@ public async Task SimulateTransactionAsync( { ArgumentNullException.ThrowIfNull(transaction); options ??= new SimulateTransactionOptions(); + if (options.SigVerify && options.ReplaceRecentBlockhash) + throw new ArgumentException( + "Signature verification cannot be combined with recent-blockhash replacement.", nameof(options)); + if (options.Accounts is not null && options.AccountsEncoding is not ( + RpcAccountEncoding.Base64 or RpcAccountEncoding.JsonParsed or RpcAccountEncoding.Base64Zstd)) + { + throw new ArgumentOutOfRangeException( + nameof(options), + options.AccountsEncoding, + "Simulation accounts support only base64, jsonParsed, and base64+zstd encoding."); + } var encoded = Convert.ToBase64String(transaction); var result = await SendAsync>( - RpcRequests.SimulateTransaction(encoded, options.SigVerify, options.ReplaceRecentBlockhash, options.Commitment, options.MinContextSlot), + RpcRequests.SimulateTransaction( + encoded, + options.SigVerify, + options.ReplaceRecentBlockhash, + options.Commitment, + options.MinContextSlot, + options.Accounts, + options.AccountsEncoding, + options.InnerInstructions), cancellationToken); - return result.Value!; + return RequireContextValue(result); } /// @@ -262,12 +425,84 @@ public async Task SimulateTransactionAsync( DataSlice? dataSlice = null, CancellationToken cancellationToken = default) { - var result = await SendAsync>( + var result = await SendAsync>( RpcRequests.GetAccountInfo(account, commitment, dataSlice), cancellationToken); return result.Value; } + /// + /// Returns an account with the exact upstream encoding, slicing, commitment, and minimum-context-slot + /// configuration. The result preserves the node's legacy, encoded-tuple, or parsed account-data branch. + /// + /// The account to query. + /// The exact upstream account configuration. + /// A token to cancel the request. + /// The account, or null if it does not exist. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task GetAccountInfoWithOptionsAsync( + PublicKey account, + RpcAccountInfoOptions options, + CancellationToken cancellationToken = default) + { + var result = await GetAccountInfoWithOptionsAndContextAsync(account, options, cancellationToken); + return result.Value; + } + + /// + /// Returns an exact-encoding account response together with the slot context used by the node. + /// + /// The account to query. + /// The exact upstream account configuration. + /// A token to cancel the request. + /// The context-wrapped account; its value is null when the account does not exist. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task> GetAccountInfoWithOptionsAndContextAsync( + PublicKey account, + RpcAccountInfoOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync>( + RpcRequests.GetAccountInfo( + account, + options.Commitment, + options.DataSlice, + options.MinContextSlot, + options.Encoding), + cancellationToken); + } + + /// Returns an account together with the slot context used by the node. + /// The account to query. + /// Commitment, data-slice, and minimum-context-slot options. + /// A token to cancel the request. + /// The context-wrapped account; its value is null when the account does not exist. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task> GetAccountInfoWithContextAsync( + PublicKey account, + GetAccountInfoOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync>( + RpcRequests.GetAccountInfo( + account, + options.Commitment, + options.DataSlice, + options.MinContextSlot), + cancellationToken); + } + /// /// Returns the accounts at the given addresses, in the same order. Each entry is null when no /// account exists at the corresponding address. See @@ -291,7 +526,85 @@ public async Task SimulateTransactionAsync( var result = await SendAsync>( RpcRequests.GetMultipleAccounts(accounts, commitment), cancellationToken); - return result.Value!; + return RequireContextValue(result); + } + + /// + /// Returns multiple accounts with the exact upstream account configuration while preserving every account-data + /// encoding branch and missing entries. + /// + /// The accounts to query. + /// The exact upstream account configuration. + /// A token to cancel the request. + /// One account entry per requested address, preserving missing entries as null. + /// or is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetMultipleAccountsWithOptionsAsync( + IReadOnlyList accounts, + RpcAccountInfoOptions options, + CancellationToken cancellationToken = default) + { + var result = await GetMultipleAccountsWithOptionsAndContextAsync(accounts, options, cancellationToken); + return RequireContextValue(result); + } + + /// + /// Returns exact-encoding accounts together with the slot context used by the node. + /// + /// The accounts to query. + /// The exact upstream account configuration. + /// A token to cancel the request. + /// The context-wrapped account array, preserving missing entries as null. + /// or is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetMultipleAccountsWithOptionsAndContextAsync( + IReadOnlyList accounts, + RpcAccountInfoOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(accounts); + ArgumentNullException.ThrowIfNull(options); + var result = await SendAsync>( + RpcRequests.GetMultipleAccounts( + accounts, + options.Commitment, + options.DataSlice, + options.MinContextSlot, + options.Encoding), + cancellationToken); + RequireContextValue(result); + return result; + } + + /// Returns multiple accounts together with the slot context used by the node. + /// The accounts to query. + /// Commitment, data-slice, and minimum-context-slot options. + /// A token to cancel the request. + /// The context-wrapped account array. + /// or is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetMultipleAccountsWithContextAsync( + IReadOnlyList accounts, + GetAccountInfoOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(accounts); + ArgumentNullException.ThrowIfNull(options); + var result = await SendAsync>( + RpcRequests.GetMultipleAccounts( + accounts, + options.Commitment, + options.DataSlice, + options.MinContextSlot), + cancellationToken); + RequireContextValue(result); + return result; } /// @@ -311,9 +624,11 @@ public async Task> GetSignaturesForAddressAsync( CancellationToken cancellationToken = default) { options ??= new GetSignaturesForAddressOptions(); - return await SendAsync( + var result = await SendAsync( RpcRequests.GetSignaturesForAddress(address, options.Limit, options.Before, options.Until, options.Commitment, options.MinContextSlot), cancellationToken); + + return RequireNonNullEntries(result, "signature list"); } /// @@ -335,14 +650,129 @@ public async Task> GetProgramAccountsAsync( CancellationToken cancellationToken = default) { options ??= new GetProgramAccountsOptions(); - return await SendAsync( - RpcRequests.GetProgramAccounts(programId, options.Commitment, options.Filters, options.DataSlice, options.MinContextSlot), + var request = RpcRequests.GetProgramAccounts( + programId, + options.Commitment, + options.Filters, + options.DataSlice, + options.MinContextSlot, + options.WithContext, + options.SortResults); + + if (options.WithContext is true) + { + var contextual = await SendAsync>(request, cancellationToken); + return RequireNonNullKeyedAccounts(RequireContextValue(contextual)); + } + + return RequireNonNullKeyedAccounts(await SendAsync(request, cancellationToken)); + } + + /// + /// Returns program-owned accounts with the exact upstream encoding, filters, slicing, context-shape, and + /// sorting configuration. Each account preserves its legacy, encoded-tuple, or parsed data branch. + /// + /// The owning program to enumerate accounts for. + /// The exact upstream program-account configuration. + /// A token to cancel the request. + /// The matching accounts; the context wrapper is unwrapped when requested. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetProgramAccountsWithOptionsAsync( + PublicKey programId, + RpcProgramAccountsOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var request = RpcRequests.GetProgramAccounts( + programId, + options.Commitment, + options.Filters, + options.DataSlice, + options.MinContextSlot, + options.WithContext, + options.SortResults, + options.Encoding); + + if (options.WithContext is true) + { + var contextual = await GetProgramAccountsWithOptionsAndContextAsync( + programId, options, cancellationToken); + return RequireContextValue(contextual); + } + + return RequireNonNullKeyedAccounts( + await SendAsync(request, cancellationToken)); + } + + /// + /// Returns exact-encoding program accounts in the upstream { context, value } response shape. + /// + /// The owning program to enumerate accounts for. + /// The exact upstream program-account configuration; context wrapping is forced. + /// A token to cancel the request. + /// The matching accounts together with the slot context used by the node. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetProgramAccountsWithOptionsAndContextAsync( + PublicKey programId, + RpcProgramAccountsOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var result = await SendAsync>( + RpcRequests.GetProgramAccounts( + programId, + options.Commitment, + options.Filters, + options.DataSlice, + options.MinContextSlot, + withContext: true, + options.SortResults, + options.Encoding), + cancellationToken); + RequireNonNullKeyedAccounts(result.Value); + return result; + } + + /// + /// Returns every account owned by a program in the upstream { context, value } shape. + /// + /// The owning program to enumerate accounts for. + /// Filters, account configuration, and sorting options; node defaults are used when null. + /// A token to cancel the request. + /// The matching accounts together with the slot context used by the node. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetProgramAccountsWithContextAsync( + PublicKey programId, + GetProgramAccountsOptions? options = null, + CancellationToken cancellationToken = default) + { + options ??= new GetProgramAccountsOptions(); + var result = await SendAsync>( + RpcRequests.GetProgramAccounts( + programId, + options.Commitment, + options.Filters, + options.DataSlice, + options.MinContextSlot, + withContext: true, + options.SortResults), cancellationToken); + RequireNonNullKeyedAccounts(result.Value); + return result; } /// /// Fetches and decodes an on-chain Address Lookup Table account. Returns null if nothing exists - /// at or the account is not an initialized lookup table. + /// at or the account is not an initialized lookup table. The returned + /// excludes addresses appended in the response's context slot. /// See getAccountInfo. /// /// The lookup table account's address. @@ -357,8 +787,17 @@ public async Task> GetProgramAccountsAsync( Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) { - var account = await GetAccountInfoAsync(tableAddress, commitment, cancellationToken: cancellationToken); - return account is null ? null : AddressLookupTable.Decode(account.Data); + var response = await GetAccountInfoWithContextAsync( + tableAddress, + new GetAccountInfoOptions { Commitment = commitment }, + cancellationToken); + var account = response.Value; + if (account is not { Executable: false } || account.Owner != AddressLookupTableProgramOwner) + return null; + + return response.Context is { } context + ? AddressLookupTable.Decode(account.Data, context.Slot) + : AddressLookupTable.Decode(account.Data); } /// @@ -376,6 +815,23 @@ public async Task GetEpochInfoAsync( CancellationToken cancellationToken = default) => await SendAsync(RpcRequests.GetEpochInfo(commitment), cancellationToken); + /// Returns epoch information with explicit commitment and minimum-context-slot options. + /// The context options sent to the node. + /// A token to cancel the request. + /// The current epoch information. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task GetEpochInfoWithOptionsAsync( + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync( + RpcRequests.GetEpochInfo(options.Commitment, options.MinContextSlot), cancellationToken); + } + /// /// Returns whether a blockhash is still valid for use as a transaction's recent blockhash. /// See isBlockhashValid. @@ -396,6 +852,27 @@ public async Task IsBlockhashValidAsync( return result.Value; } + /// Checks blockhash validity with explicit commitment and minimum-context-slot options. + /// The blockhash (base58) to check. + /// The context options sent to the node. + /// A token to cancel the request. + /// true if the blockhash is still valid. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task IsBlockhashValidWithOptionsAsync( + string blockhash, + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var result = await SendAsync>( + RpcRequests.IsBlockhashValid(blockhash, options.Commitment, options.MinContextSlot), cancellationToken); + + return result.Value; + } + /// /// Returns the fee the cluster would charge to process a message, or null if its blockhash has expired. /// See getFeeForMessage. @@ -420,6 +897,30 @@ public async Task IsBlockhashValidAsync( return result.Value; } + /// Returns a message fee with explicit commitment and minimum-context-slot options. + /// The message's serialized wire bytes. + /// The context options sent to the node. + /// A token to cancel the request. + /// The fee in lamports, or null if the recent blockhash expired. + /// or is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task GetFeeForMessageWithOptionsAsync( + byte[] message, + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + ArgumentNullException.ThrowIfNull(options); + + var encoded = Convert.ToBase64String(message); + var result = await SendAsync>( + RpcRequests.GetFeeForMessage(encoded, options.Commitment, options.MinContextSlot), cancellationToken); + + return result.Value; + } + /// /// Requests an airdrop of lamports to an account (test clusters only). /// See requestAirdrop. @@ -439,6 +940,27 @@ public Task RequestAirdropAsync( CancellationToken cancellationToken = default) => SendAsync(RpcRequests.RequestAirdrop(account, lamports, commitment), cancellationToken); + /// Requests an airdrop with an optional caller-selected recent blockhash. + /// The account to fund. + /// The amount to airdrop, in lamports. + /// Recent-blockhash and commitment options. + /// A token to cancel the request. + /// The airdrop transaction signature (base58). + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task RequestAirdropWithOptionsAsync( + PublicKey account, + ulong lamports, + RequestAirdropOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync( + RpcRequests.RequestAirdrop(account, lamports, options.Commitment, options.RecentBlockhash), cancellationToken); + } + /// /// Returns the SPL token accounts owned by for a specific . /// Account data is requested as base64 and exposed decoded on . @@ -459,9 +981,122 @@ public async Task> GetTokenAccountsByOwnerAsync( CancellationToken cancellationToken = default) { var result = await SendAsync>( - RpcRequests.GetTokenAccountsByOwner(owner, mint, commitment), cancellationToken); + RpcRequests.GetTokenAccountsByOwner( + owner, + TokenAccountsFilter.ByMint(mint), + commitment), + cancellationToken); - return result.Value!; + return RequireNonNullKeyedAccounts(RequireContextValue(result)); + } + + /// + /// Returns token accounts owned by an address, filtered by either mint or SPL Token program. + /// + /// The account that owns the token accounts. + /// The mutually exclusive mint or token-program filter. + /// Base64 slicing, commitment, and minimum-context-slot options; node defaults when null. + /// A token to cancel the request. + /// The matching token accounts. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetTokenAccountsByOwnerWithFilterAsync( + PublicKey owner, + TokenAccountsFilter filter, + GetAccountInfoOptions? options = null, + CancellationToken cancellationToken = default) + { + var result = await GetTokenAccountsByOwnerWithContextAsync(owner, filter, options, cancellationToken); + return RequireNonNullKeyedAccounts(RequireContextValue(result)); + } + + /// + /// Returns filtered token accounts with the exact upstream account encoding and read configuration. + /// + /// The account that owns the token accounts. + /// The mutually exclusive mint or token-program filter. + /// The exact upstream account configuration. + /// A token to cancel the request. + /// The matching token accounts with exact account-data branches. + /// or is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetTokenAccountsByOwnerWithOptionsAsync( + PublicKey owner, + TokenAccountsFilter filter, + RpcAccountInfoOptions options, + CancellationToken cancellationToken = default) + { + var result = await GetTokenAccountsByOwnerWithOptionsAndContextAsync( + owner, filter, options, cancellationToken); + + return RequireContextValue(result); + } + + /// + /// Returns exact-encoding filtered token accounts together with the slot context used by the node. + /// + /// The account that owns the token accounts. + /// The mutually exclusive mint or token-program filter. + /// The exact upstream account configuration. + /// A token to cancel the request. + /// The context-wrapped matching token accounts. + /// or is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetTokenAccountsByOwnerWithOptionsAndContextAsync( + PublicKey owner, + TokenAccountsFilter filter, + RpcAccountInfoOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(options); + var result = await SendAsync>( + RpcRequests.GetTokenAccountsByOwner( + owner, + filter, + options.Commitment, + options.DataSlice, + options.MinContextSlot, + options.Encoding), + cancellationToken); + RequireNonNullKeyedAccounts(result.Value); + return result; + } + + /// Returns filtered token accounts owned by an address together with their slot context. + /// The account that owns the token accounts. + /// The mutually exclusive mint or token-program filter. + /// Base64 slicing, commitment, and minimum-context-slot options; node defaults when null. + /// A token to cancel the request. + /// The context-wrapped matching token accounts. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetTokenAccountsByOwnerWithContextAsync( + PublicKey owner, + TokenAccountsFilter filter, + GetAccountInfoOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(filter); + options ??= new GetAccountInfoOptions(); + var result = await SendAsync>( + RpcRequests.GetTokenAccountsByOwner( + owner, + filter, + options.Commitment, + options.DataSlice, + options.MinContextSlot), + cancellationToken); + RequireNonNullKeyedAccounts(result.Value); + return result; } /// @@ -477,11 +1112,14 @@ public async Task> GetTokenAccountsByOwnerAsync( public async Task> GetRecentPrioritizationFeesAsync( IReadOnlyList? accounts = null, CancellationToken cancellationToken = default) - => await SendAsync(RpcRequests.GetRecentPrioritizationFees(accounts ?? []), cancellationToken); + => RequireNonNullEntries( + await SendAsync(RpcRequests.GetRecentPrioritizationFees(accounts ?? []), cancellationToken), + "prioritization-fee list"); /// /// Returns a confirmed transaction by signature, or null if the cluster has not seen it. Supports - /// versioned (v0) transactions. See + /// legacy and versioned-v0 transactions. Use to opt into + /// a newer numeric version. See /// getTransaction. /// /// The transaction signature (base58). @@ -495,7 +1133,59 @@ public async Task> GetRecentPrioritizationFeesA string signature, Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) - => SendAsync(RpcRequests.GetTransaction(signature, commitment), cancellationToken); + => SendNullableAsync(RpcRequests.GetTransaction(signature, commitment, 0), cancellationToken); + + /// + /// Returns a confirmed transaction while explicitly opting into a newer transaction version. The + /// transaction is returned as wire bytes on ; callers must + /// only advertise versions whose wire format they can handle. + /// + /// The transaction signature (base58). + /// + /// The highest numeric transaction version the caller accepts. This byte is sent unchanged; the node + /// decides whether the requested transaction is available at that version. + /// + /// The commitment level to query at. + /// A token to cancel the request. + /// The transaction and its execution metadata, or null if it was not found. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task GetTransactionWithMaxVersionAsync( + string signature, + byte maxSupportedTransactionVersion, + Commitment commitment = Commitment.Confirmed, + CancellationToken cancellationToken = default) + => SendNullableAsync( + RpcRequests.GetTransaction(signature, commitment, maxSupportedTransactionVersion), cancellationToken); + + /// + /// Returns a transaction using the exact upstream encoding, commitment, and transaction-version + /// configuration. Because the encoding changes the transaction field's JSON schema, the result is exposed + /// without lossy projection as a . + /// + /// The transaction signature (base58). + /// The exact upstream transaction configuration. + /// A token to cancel the request. + /// The configured transaction JSON, or null if the transaction was not found. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task GetTransactionWithOptionsAsync( + string signature, + GetTransactionOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendNullableAsync( + RpcRequests.GetTransaction( + signature, + options.Commitment, + options.MaxSupportedTransactionVersion, + options.Encoding), + cancellationToken); + } /// /// Returns the processing status of each signature, in order; an entry is null if the cluster has @@ -520,7 +1210,7 @@ public async Task> GetRecentPrioritizationFeesA var result = await SendAsync>( RpcRequests.GetSignatureStatuses(signatures, searchTransactionHistory), cancellationToken); - return result.Value!; + return RequireContextValue(result); } /// @@ -534,6 +1224,7 @@ public async Task> GetRecentPrioritizationFeesA /// A token to cancel the wait. /// The signature's status once it reaches . /// is null. + /// is negative and not infinite. /// The transaction did not reach in time. /// The node returned a JSON-RPC error. /// The was cancelled. @@ -545,20 +1236,44 @@ public async Task ConfirmTransactionAsync( { ArgumentNullException.ThrowIfNull(signature); - var deadline = DateTimeOffset.UtcNow + (timeout ?? DefaultConfirmationTimeout); - var target = CommitmentRank(commitment); + var confirmationTimeout = timeout ?? DefaultConfirmationTimeout; + if (confirmationTimeout < TimeSpan.Zero && confirmationTimeout != Timeout.InfiniteTimeSpan) + throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "The confirmation timeout must be non-negative or infinite."); - while (true) - { - var statuses = await GetSignatureStatusesAsync([signature], searchTransactionHistory: false, cancellationToken); - var status = statuses.Count > 0 ? statuses[0] : null; - if (status is not null && StatusRank(status.ConfirmationStatus) >= target) - return status; + using var timeoutCts = new CancellationTokenSource(); + var timeoutTask = confirmationTimeout == Timeout.InfiniteTimeSpan + ? Task.CompletedTask + : CancelAfterAsync(timeoutCts, confirmationTimeout); - if (DateTimeOffset.UtcNow >= deadline) - throw new TimeoutException($"Transaction {signature} was not confirmed at {commitment} within the timeout."); + try + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + var target = CommitmentRank(commitment); - await Task.Delay(ConfirmationPollInterval, cancellationToken); + try + { + while (true) + { + var statuses = await GetSignatureStatusesAsync( + [signature], searchTransactionHistory: false, linkedCts.Token); + var status = statuses.Count > 0 ? statuses[0] : null; + if (status is not null && StatusRank(status) >= target) + return status; + + await Task.Delay(ConfirmationPollInterval, linkedCts.Token); + } + } + catch (OperationCanceledException exception) + when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"Transaction {signature} was not confirmed at {commitment} within the timeout.", exception); + } + } + finally + { + await timeoutCts.CancelAsync(); + await timeoutTask; } } @@ -568,7 +1283,7 @@ public async Task ConfirmTransactionAsync( /// on-chain, so a returned signature always means success. /// /// The signed transaction's serialized wire bytes. - /// Send options; node defaults are used when null. + /// Send options; client defaults are used when null. /// The commitment level to wait for. /// How long to wait for confirmation before giving up; defaults to 60 seconds. /// A token to cancel the send or wait. @@ -603,20 +1318,55 @@ public async Task SendAndConfirmTransactionAsync( _ => 1 }; - private static int StatusRank(string? confirmationStatus) => confirmationStatus switch + private static int StatusRank(SignatureStatus status) { - "processed" => 0, - "confirmed" => 1, - "finalized" => 2, - _ => -1 - }; + if (status.ConfirmationStatus is not null) + { + return status.ConfirmationStatus switch + { + "processed" => 0, + "confirmed" => 1, + "finalized" => 2, + _ => -1 + }; + } + + // Older nodes omitted confirmationStatus. Match the upstream confirmation loop exactly: + // zero or one confirmation is still processed, more than one is confirmed, and null is rooted/finalized. + return status.Confirmations switch + { + null => 2, + > 1 => 1, + _ => 0 + }; + } private static readonly TimeSpan DefaultConfirmationTimeout = TimeSpan.FromSeconds(60); private static readonly TimeSpan ConfirmationPollInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan MaximumTimerDelay = TimeSpan.FromMilliseconds(uint.MaxValue - 1d); + + private static async Task CancelAfterAsync(CancellationTokenSource source, TimeSpan timeout) + { + try + { + while (timeout > MaximumTimerDelay) + { + await Task.Delay(MaximumTimerDelay, source.Token); + timeout -= MaximumTimerDelay; + } + + await Task.Delay(timeout, source.Token); + await source.CancelAsync(); + } + catch (OperationCanceledException) when (source.IsCancellationRequested) + { + // Confirmation finished or caller cancellation won; the timeout task only needs to stop. + } + } /// - /// Fetches and decodes an SPL Token mint account, or returns null if nothing exists at - /// or the account is too short to be a mint. + /// Fetches and decodes a classic Token or Token-2022 mint account, or returns null if nothing + /// exists at or its owner and data layout do not identify a mint. /// /// The mint account's address. /// The commitment level to query at. @@ -631,7 +1381,13 @@ public async Task SendAndConfirmTransactionAsync( CancellationToken cancellationToken = default) { var account = await GetAccountInfoAsync(mint, commitment, cancellationToken: cancellationToken); - return account is null ? null : Mint.Decode(account.Data); + if (account is not { Executable: false }) + return null; + + if (account.Owner == TokenProgramOwner) + return account.Data.Length == Mint.Length ? Mint.Decode(account.Data) : null; + + return account.Owner == Token2022ProgramOwner ? Mint.Decode(account.Data) : null; } /// @@ -652,12 +1408,14 @@ public async Task SendAndConfirmTransactionAsync( CancellationToken cancellationToken = default) { var account = await GetAccountInfoAsync(nonceAccount, commitment, cancellationToken: cancellationToken); - return account is null ? null : NonceAccount.Decode(account.Data); + return account is { Executable: false } && account.Owner == SystemProgramOwner + ? NonceAccount.Decode(account.Data) + : null; } /// - /// Fetches and decodes an SPL Token account, or returns null if nothing exists at - /// or the account is too short to be a token account. + /// Fetches and decodes a classic Token or Token-2022 holding account, or returns null if nothing + /// exists at or its owner and data layout do not identify one. /// /// The token account's address. /// The commitment level to query at. @@ -672,7 +1430,13 @@ public async Task SendAndConfirmTransactionAsync( CancellationToken cancellationToken = default) { var account = await GetAccountInfoAsync(tokenAccount, commitment, cancellationToken: cancellationToken); - return account is null ? null : TokenAccount.Decode(account.Data); + if (account is not { Executable: false }) + return null; + + if (account.Owner == TokenProgramOwner) + return account.Data.Length == TokenAccount.Length ? TokenAccount.Decode(account.Data) : null; + + return account.Owner == Token2022ProgramOwner ? TokenAccount.Decode(account.Data) : null; } /// @@ -692,6 +1456,20 @@ public async Task> GetSlotLeadersAsync( CancellationToken cancellationToken = default) => await SendAsync(RpcRequests.GetSlotLeaders(startSlot, limit), cancellationToken); + /// + /// Returns the Alpenglow genesis block certificate, or null while the cluster is still using + /// Tower BFT consensus. + /// See getAgGenesisCert. + /// + /// A token to cancel the request. + /// The Alpenglow genesis certificate, or null when Alpenglow is not active. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task GetAgGenesisCertificateAsync( + CancellationToken cancellationToken = default) + => SendNullableAsync(RpcRequests.GetAgGenesisCert(), cancellationToken); + /// /// Returns the cluster's total, circulating, and non-circulating token supply. /// See getSupply. @@ -706,8 +1484,28 @@ public async Task GetSupplyAsync( Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) { - var result = await SendAsync>(RpcRequests.GetSupply(commitment), cancellationToken); - return result.Value!; + var result = await SendAsync>( + RpcRequests.GetSupply(commitment, excludeNonCirculatingAccountsList: true), cancellationToken); + return RequireContextValue(result); + } + + /// Returns cluster supply with explicit control over the non-circulating account list. + /// Commitment and account-list exclusion options. + /// A token to cancel the request. + /// The supply totals and, unless excluded, non-circulating account addresses. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task GetSupplyWithOptionsAsync( + GetSupplyOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var result = await SendAsync>( + RpcRequests.GetSupply(options.Commitment, options.ExcludeNonCirculatingAccountsList), cancellationToken); + + return RequireContextValue(result); } /// @@ -717,37 +1515,88 @@ public async Task GetSupplyAsync( /// The token mint to query. /// The commitment level to query at. /// A token to cancel the request. - /// The largest token accounts, largest first. + /// The largest token accounts, largest first. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetTokenLargestAccountsAsync( + PublicKey mint, + Commitment commitment = Commitment.Confirmed, + CancellationToken cancellationToken = default) + { + var result = await SendAsync>( + RpcRequests.GetTokenLargestAccounts(mint, commitment), cancellationToken); + + return RequireNonNullEntries(RequireContextValue(result), "token-largest-account list"); + } + + /// + /// Returns a confirmed block by slot (with transaction signatures only), or null if the slot was + /// skipped. See getBlock. + /// + /// The slot to fetch the block for. + /// The commitment level to query at (processed is not supported by the node). + /// A token to cancel the request. + /// The block, or null if the slot was skipped and produced no block. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task GetBlockAsync( + ulong slot, + Commitment commitment = Commitment.Confirmed, + CancellationToken cancellationToken = default) + => SendNullableAsync(RpcRequests.GetBlock(slot, commitment, 0), cancellationToken); + + /// + /// Returns a confirmed signatures-only block while explicitly opting into a newer transaction version. + /// This is required when the block contains a version newer than v0, even though only signatures are + /// requested. See getBlock. + /// + /// The slot to fetch the block for. + /// The highest numeric transaction version the caller accepts. + /// The commitment level to query at (processed is not supported by the node). + /// A token to cancel the request. + /// The block, or null if the slot was skipped and produced no block. /// The node returned a JSON-RPC error. /// The request failed at the transport level or returned a non-success status. /// The was cancelled. - public async Task> GetTokenLargestAccountsAsync( - PublicKey mint, + public Task GetBlockWithMaxVersionAsync( + ulong slot, + byte maxSupportedTransactionVersion, Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) - { - var result = await SendAsync>( - RpcRequests.GetTokenLargestAccounts(mint, commitment), cancellationToken); - - return result.Value!; - } + => SendNullableAsync( + RpcRequests.GetBlock(slot, commitment, maxSupportedTransactionVersion), cancellationToken); /// - /// Returns a confirmed block by slot (with transaction signatures only), or null if the slot was - /// skipped. See getBlock. + /// Returns a block using the exact upstream encoding, transaction-details, rewards, commitment, and + /// transaction-version configuration. Because those choices change the JSON schema, the result is exposed + /// without lossy projection as a . /// /// The slot to fetch the block for. - /// The commitment level to query at (processed is not supported by the node). + /// The exact upstream block configuration. /// A token to cancel the request. - /// The block, or null if the slot was skipped and produced no block. + /// The configured block JSON, or null if the slot was skipped. + /// is null. /// The node returned a JSON-RPC error. /// The request failed at the transport level or returned a non-success status. /// The was cancelled. - public Task GetBlockAsync( + public Task GetBlockWithOptionsAsync( ulong slot, - Commitment commitment = Commitment.Confirmed, + GetBlockOptions options, CancellationToken cancellationToken = default) - => SendAsync(RpcRequests.GetBlock(slot, commitment), cancellationToken); + { + ArgumentNullException.ThrowIfNull(options); + return SendNullableAsync( + RpcRequests.GetBlock( + slot, + options.Commitment, + options.MaxSupportedTransactionVersion, + options.Encoding, + options.TransactionDetails, + options.Rewards), + cancellationToken); + } /// /// Returns a confirmed transaction decoded by the node into jsonParsed form - recognized @@ -761,12 +1610,50 @@ public async Task> GetTokenLargestAccountsAsy /// The node returned a JSON-RPC error. /// The request failed at the transport level or returned a non-success status. /// The was cancelled. - public Task GetParsedTransactionAsync( + public async Task GetParsedTransactionAsync( + string signature, + Commitment? commitment = null, + CancellationToken cancellationToken = default) + { + var transaction = await SendNullableAsync( + RpcRequests.GetParsedTransaction(signature, commitment ?? Commitment.Confirmed, 0), cancellationToken); + return RequireConfirmedParsedTransaction(transaction); + } + + /// + /// Returns a node-decoded transaction while explicitly opting into a newer numeric transaction version. + /// For V1, exposes the message's execution configuration. + /// + /// The transaction signature (base58) to fetch. + /// The highest numeric transaction version the caller accepts. + /// The commitment level to query at; defaults to when null. + /// A token to cancel the request. + /// The parsed transaction, or null if no transaction with that signature was found. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task GetParsedTransactionWithMaxVersionAsync( string signature, + byte maxSupportedTransactionVersion, Commitment? commitment = null, CancellationToken cancellationToken = default) - => SendAsync( - RpcRequests.GetParsedTransaction(signature, commitment ?? Commitment.Confirmed), cancellationToken); + { + var transaction = await SendNullableAsync( + RpcRequests.GetParsedTransaction( + signature, + commitment ?? Commitment.Confirmed, + maxSupportedTransactionVersion), + cancellationToken); + return RequireConfirmedParsedTransaction(transaction); + } + + private static ParsedTransaction? RequireConfirmedParsedTransaction(ParsedTransaction? transaction) + { + if (transaction is not null && transaction.Slot is null) + throw new JsonException("A getTransaction response must carry a slot and block-time member."); + + return transaction; + } /// /// Returns a confirmed block whose transactions are decoded by the node into jsonParsed form, or @@ -785,15 +1672,52 @@ public async Task> GetTokenLargestAccountsAsy ulong slot, Commitment? commitment = null, CancellationToken cancellationToken = default) + => await GetParsedBlockCoreAsync(slot, 0, commitment, cancellationToken); + + /// + /// Returns a node-decoded block while explicitly opting into a newer numeric transaction version. + /// For V1 messages, exposes the embedded execution settings. + /// + /// The slot to fetch the block for. + /// The highest numeric transaction version the caller accepts. + /// The commitment level to query at; defaults to when null. + /// A token to cancel the request. + /// The block with parsed transactions, or null if the slot was skipped and produced no block. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task GetParsedBlockWithMaxVersionAsync( + ulong slot, + byte maxSupportedTransactionVersion, + Commitment? commitment = null, + CancellationToken cancellationToken = default) + => await GetParsedBlockCoreAsync(slot, maxSupportedTransactionVersion, commitment, cancellationToken); + + private async Task GetParsedBlockCoreAsync( + ulong slot, + byte maxSupportedTransactionVersion, + Commitment? commitment, + CancellationToken cancellationToken) { - var block = await SendAsync( - RpcRequests.GetParsedBlock(slot, commitment ?? Commitment.Confirmed), cancellationToken); + var block = await SendNullableAsync( + RpcRequests.GetParsedBlock( + slot, + commitment ?? Commitment.Confirmed, + maxSupportedTransactionVersion), + cancellationToken); if (block is null) return null; + // getBlock emits the flattened transaction vector in ledger order. Upstream derives + // transactionIndex from this same zero-based order when serving getTransaction. var transactions = block.Transactions - .Select(transaction => transaction with { Slot = slot, BlockTime = block.BlockTime }) + .Select((transaction, index) => transaction with + { + Slot = slot, + BlockTime = block.BlockTime, + TransactionIndex = (uint)index + }) .ToArray(); return block with { Transactions = transactions }; @@ -814,6 +1738,28 @@ public Task GetVoteAccountsAsync( CancellationToken cancellationToken = default) => SendAsync(RpcRequests.GetVoteAccounts(commitment), cancellationToken); + /// Returns vote accounts using the complete upstream vote-account configuration. + /// Vote address, commitment, and delinquency options. + /// A token to cancel the request. + /// The matching current and delinquent vote accounts. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task GetVoteAccountsWithOptionsAsync( + GetVoteAccountsOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync( + RpcRequests.GetVoteAccounts( + options.Commitment, + options.VotePublicKey, + options.KeepUnstakedDelinquents, + options.DelinquentSlotDistance), + cancellationToken); + } + /// /// Returns the inflation / staking reward paid to each of for an epoch. /// See getInflationReward. @@ -823,6 +1769,7 @@ public Task GetVoteAccountsAsync( /// The commitment level to query at. /// A token to cancel the request. /// The reward for each address in order; an entry is null when that address earned no reward. + /// is null. /// The node returned a JSON-RPC error. /// The request failed at the transport level or returned a non-success status. /// The was cancelled. @@ -831,8 +1778,36 @@ public Task GetVoteAccountsAsync( ulong? epoch = null, Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) - => SendAsync>( + { + ArgumentNullException.ThrowIfNull(addresses); + return SendAsync>( RpcRequests.GetInflationReward(addresses, epoch, commitment), cancellationToken); + } + + /// Returns inflation rewards with explicit epoch and minimum-context-slot options. + /// The addresses to look up rewards for. + /// Epoch, commitment, and minimum-context-slot options. + /// A token to cancel the request. + /// The reward for each address in order; missing rewards are null. + /// or is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task> GetInflationRewardWithOptionsAsync( + IReadOnlyList addresses, + GetInflationRewardOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(addresses); + ArgumentNullException.ThrowIfNull(options); + return SendAsync>( + RpcRequests.GetInflationReward( + addresses, + options.Epoch, + options.Commitment, + options.MinContextSlot), + cancellationToken); + } /// /// Returns the leader schedule for an epoch - a map of validator identity to the slot indices (relative to @@ -846,13 +1821,30 @@ public Task GetVoteAccountsAsync( /// The node returned a JSON-RPC error. /// The request failed at the transport level or returned a non-success status. /// The was cancelled. - public Task>?> GetLeaderScheduleAsync( + public Task>?> GetLeaderScheduleAsync( ulong? slot = null, Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) - => SendAsync>?>( + => SendNullableAsync>?>( RpcRequests.GetLeaderSchedule(slot, commitment), cancellationToken); + /// Returns a leader schedule with optional validator-identity filtering. + /// Slot, identity, and commitment options. + /// A token to cancel the request. + /// The leader schedule, or null when unavailable. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task>?> GetLeaderScheduleWithOptionsAsync( + GetLeaderScheduleOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendNullableAsync>?>( + RpcRequests.GetLeaderSchedule(options.Slot, options.Commitment, options.Identity), cancellationToken); + } + /// /// Returns the confirmed block slots from through /// (inclusive). See getBlocks. @@ -872,6 +1864,27 @@ public Task> GetBlocksAsync( CancellationToken cancellationToken = default) => SendAsync>(RpcRequests.GetBlocks(startSlot, endSlot, commitment), cancellationToken); + /// Returns confirmed block slots with explicit commitment and minimum-context-slot options. + /// The first slot of the range. + /// The last slot of the range, or null for the latest confirmed block. + /// The context options sent to the node. + /// A token to cancel the request. + /// The slots that produced a block, in ascending order. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task> GetBlocksWithOptionsAsync( + ulong startSlot, + ulong? endSlot, + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync>( + RpcRequests.GetBlocks(startSlot, endSlot, options.Commitment, options.MinContextSlot), cancellationToken); + } + /// /// Returns information about the nodes participating in the cluster. /// See getClusterNodes. @@ -881,8 +1894,10 @@ public Task> GetBlocksAsync( /// The node returned a JSON-RPC error. /// The request failed at the transport level or returned a non-success status. /// The was cancelled. - public Task> GetClusterNodesAsync(CancellationToken cancellationToken = default) - => SendAsync>(RpcRequests.GetClusterNodes(), cancellationToken); + public async Task> GetClusterNodesAsync(CancellationToken cancellationToken = default) + => RequireNonNullEntries( + await SendAsync(RpcRequests.GetClusterNodes(), cancellationToken), + "cluster-node list"); /// /// Returns the account at decoded with jsonParsed encoding, or null @@ -900,12 +1915,49 @@ public Task> GetClusterNodesAsync(CancellationToken c Commitment? commitment = null, CancellationToken cancellationToken = default) { - var result = await SendAsync>( + var result = await SendAsync>( RpcRequests.GetParsedAccountInfo(account, commitment ?? Commitment.Confirmed), cancellationToken); return result.Value; } + /// Returns a parsed account with explicit commitment and minimum-context-slot options. + /// The account to fetch. + /// The context options sent to the node. + /// A token to cancel the request. + /// The parsed account, or null if it does not exist. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task GetParsedAccountInfoWithOptionsAsync( + PublicKey account, + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + var result = await GetParsedAccountInfoWithContextAsync(account, options, cancellationToken); + return result.Value; + } + + /// Returns a parsed account together with the slot context used by the node. + /// The account to fetch. + /// The context options sent to the node. + /// A token to cancel the request. + /// The context-wrapped parsed account; its value is null when the account does not exist. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task> GetParsedAccountInfoWithContextAsync( + PublicKey account, + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync>( + RpcRequests.GetParsedAccountInfo(account, options.Commitment, options.MinContextSlot), cancellationToken); + } + /// /// Returns how much cluster stake has voted on a block at each confirmation depth, along with the /// epoch's total active stake. @@ -944,7 +1996,7 @@ public async Task GetBlockProductionAsync( var result = await SendAsync>( RpcRequests.GetBlockProduction(commitment, identity, firstSlot, lastSlot), cancellationToken); - return result.Value!; + return RequireContextValue(result); } /// @@ -958,7 +2010,7 @@ public async Task GetBlockProductionAsync( /// The request failed at the transport level or returned a non-success status. /// The was cancelled. public Task GetBlockTimeAsync(ulong slot, CancellationToken cancellationToken = default) - => SendAsync(RpcRequests.GetBlockTime(slot), cancellationToken); + => SendNullableAsync(RpcRequests.GetBlockTime(slot), cancellationToken); /// /// Returns up to confirmed block slots starting at . @@ -979,6 +2031,32 @@ public Task> GetBlocksWithLimitAsync( CancellationToken cancellationToken = default) => SendAsync>(RpcRequests.GetBlocksWithLimit(startSlot, limit, commitment), cancellationToken); + /// Returns a limited block-slot range with explicit commitment and minimum-context-slot options. + /// The first slot of the range. + /// The maximum number of slots to return. + /// The context options sent to the node. + /// A token to cancel the request. + /// The slots that produced a block, in ascending order. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task> GetBlocksWithLimitWithOptionsAsync( + ulong startSlot, + ulong limit, + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync>( + RpcRequests.GetBlocksWithLimit( + startSlot, + limit, + options.Commitment, + options.MinContextSlot), + cancellationToken); + } + /// /// Returns the cluster's epoch schedule (epoch length, warmup, leader-schedule offset). /// See getEpochSchedule. @@ -1089,7 +2167,26 @@ public async Task> GetLargestAccountsAsync( var result = await SendAsync>( RpcRequests.GetLargestAccounts(commitment, filter), cancellationToken); - return result.Value!; + return RequireNonNullEntries(RequireContextValue(result), "largest-account list"); + } + + /// Returns the largest accounts with explicit filtering and server-side sorting control. + /// Commitment, circulating-supply filter, and sorting options. + /// A token to cancel the request. + /// The largest accounts. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetLargestAccountsWithOptionsAsync( + GetLargestAccountsOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var result = await SendAsync>( + RpcRequests.GetLargestAccounts(options.Commitment, options.Filter, options.SortResults), cancellationToken); + + return RequireNonNullEntries(RequireContextValue(result), "largest-account list"); } /// @@ -1129,7 +2226,9 @@ public Task GetMaxShredInsertSlotAsync(CancellationToken cancellationToke public async Task> GetRecentPerformanceSamplesAsync( int? limit = null, CancellationToken cancellationToken = default) - => await SendAsync(RpcRequests.GetRecentPerformanceSamples(limit), cancellationToken); + => RequireNonNullEntries( + await SendAsync(RpcRequests.GetRecentPerformanceSamples(limit), cancellationToken), + "performance-sample list"); /// /// Returns the identity of the current slot leader. @@ -1146,6 +2245,23 @@ public Task GetSlotLeaderAsync( CancellationToken cancellationToken = default) => SendAsync(RpcRequests.GetSlotLeader(commitment), cancellationToken); + /// Returns the current slot leader with explicit commitment and minimum-context-slot options. + /// The context options sent to the node. + /// A token to cancel the request. + /// The current slot leader's identity. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public Task GetSlotLeaderWithOptionsAsync( + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + return SendAsync( + RpcRequests.GetSlotLeader(options.Commitment, options.MinContextSlot), cancellationToken); + } + /// /// Returns the cluster's minimum stake delegation, in lamports. /// See getStakeMinimumDelegation. @@ -1166,6 +2282,25 @@ public async Task GetStakeMinimumDelegationAsync( return result.Value; } + /// Returns minimum stake delegation with explicit commitment and minimum-context-slot options. + /// The context options sent to the node. + /// A token to cancel the request. + /// The minimum delegation in lamports. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task GetStakeMinimumDelegationWithOptionsAsync( + RpcContextOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var result = await SendAsync>( + RpcRequests.GetStakeMinimumDelegation(options.Commitment, options.MinContextSlot), cancellationToken); + + return result.Value; + } + /// /// Returns the SPL token accounts approved to for a specific /// . Account data is requested as base64 and exposed decoded on @@ -1187,9 +2322,124 @@ public async Task> GetTokenAccountsByDelegateAsync CancellationToken cancellationToken = default) { var result = await SendAsync>( - RpcRequests.GetTokenAccountsByDelegate(delegateAccount, mint, commitment), cancellationToken); + RpcRequests.GetTokenAccountsByDelegate( + delegateAccount, + TokenAccountsFilter.ByMint(mint), + commitment), + cancellationToken); + + return RequireNonNullKeyedAccounts(RequireContextValue(result)); + } + + /// + /// Returns token accounts approved to a delegate, filtered by either mint or SPL Token program. + /// + /// The delegate the token accounts are approved to. + /// The mutually exclusive mint or token-program filter. + /// Base64 slicing, commitment, and minimum-context-slot options; node defaults when null. + /// A token to cancel the request. + /// The matching token accounts. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetTokenAccountsByDelegateWithFilterAsync( + PublicKey delegateAccount, + TokenAccountsFilter filter, + GetAccountInfoOptions? options = null, + CancellationToken cancellationToken = default) + { + var result = await GetTokenAccountsByDelegateWithContextAsync( + delegateAccount, filter, options, cancellationToken); + + return RequireNonNullKeyedAccounts(RequireContextValue(result)); + } + + /// + /// Returns filtered delegated token accounts with the exact upstream account encoding and read configuration. + /// + /// The delegate the token accounts are approved to. + /// The mutually exclusive mint or token-program filter. + /// The exact upstream account configuration. + /// A token to cancel the request. + /// The matching token accounts with exact account-data branches. + /// or is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetTokenAccountsByDelegateWithOptionsAsync( + PublicKey delegateAccount, + TokenAccountsFilter filter, + RpcAccountInfoOptions options, + CancellationToken cancellationToken = default) + { + var result = await GetTokenAccountsByDelegateWithOptionsAndContextAsync( + delegateAccount, filter, options, cancellationToken); + + return RequireContextValue(result); + } + + /// + /// Returns exact-encoding delegated token accounts together with the slot context used by the node. + /// + /// The delegate the token accounts are approved to. + /// The mutually exclusive mint or token-program filter. + /// The exact upstream account configuration. + /// A token to cancel the request. + /// The context-wrapped matching token accounts. + /// or is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetTokenAccountsByDelegateWithOptionsAndContextAsync( + PublicKey delegateAccount, + TokenAccountsFilter filter, + RpcAccountInfoOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(options); + var result = await SendAsync>( + RpcRequests.GetTokenAccountsByDelegate( + delegateAccount, + filter, + options.Commitment, + options.DataSlice, + options.MinContextSlot, + options.Encoding), + cancellationToken); + RequireNonNullKeyedAccounts(result.Value); + return result; + } - return result.Value!; + /// Returns filtered delegated token accounts together with their slot context. + /// The delegate the token accounts are approved to. + /// The mutually exclusive mint or token-program filter. + /// Base64 slicing, commitment, and minimum-context-slot options; node defaults when null. + /// A token to cancel the request. + /// The context-wrapped matching token accounts. + /// is null. + /// The node returned a JSON-RPC error. + /// The request failed at the transport level or returned a non-success status. + /// The was cancelled. + public async Task> GetTokenAccountsByDelegateWithContextAsync( + PublicKey delegateAccount, + TokenAccountsFilter filter, + GetAccountInfoOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(filter); + options ??= new GetAccountInfoOptions(); + var result = await SendAsync>( + RpcRequests.GetTokenAccountsByDelegate( + delegateAccount, + filter, + options.Commitment, + options.DataSlice, + options.MinContextSlot), + cancellationToken); + RequireNonNullKeyedAccounts(result.Value); + return result; } /// @@ -1214,33 +2464,128 @@ public Task GetMinimumLedgerSlotAsync(CancellationToken cancellationToken internal async Task SendBatchAsync(IReadOnlyList requests, CancellationToken cancellationToken) { - using var response = await httpClient - .PostAsJsonAsync(string.Empty, requests, RpcJson.TypeInfo>(), cancellationToken); + using var message = new HttpRequestMessage(HttpMethod.Post, string.Empty) + { + Content = JsonContent.Create(requests, RpcJson.TypeInfo>()) + }; + using var response = await _httpClient.SendAsync( + message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); - using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + var body = await ReadResponseBodyAsync(response.Content, cancellationToken); + using var document = JsonDocument.Parse(body); return document.RootElement.Clone(); } private async Task SendAsync(RpcRequest request, CancellationToken cancellationToken) + => await SendCoreAsync(request, allowNullResult: false, cancellationToken); + + private async Task SendNullableAsync(RpcRequest request, CancellationToken cancellationToken) + => await SendCoreAsync(request, allowNullResult: true, cancellationToken); + + private async Task SendCoreAsync( + RpcRequest request, + bool allowNullResult, + CancellationToken cancellationToken) { - using var response = await httpClient - .PostAsJsonAsync(string.Empty, request, RpcJson.TypeInfo(), cancellationToken); + using var message = new HttpRequestMessage(HttpMethod.Post, string.Empty) + { + Content = JsonContent.Create(request, RpcJson.TypeInfo()) + }; + if (request.Method == RpcMethods.RequestAirdrop) + message.Options.Set(DisableRetriesKey, true); + + using var response = await _httpClient.SendAsync( + message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsByteArrayAsync(cancellationToken); - return DeserializeEnvelope(body, request.Id); + var body = await ReadResponseBodyAsync(response.Content, cancellationToken); + return DeserializeEnvelope(body.Span, request.Id, allowNullResult); + } + + private static T[] RequireNonNullKeyedAccounts(T[]? accounts) + where T : class + { + if (accounts is null) + throw new JsonException("An RPC keyed-account list cannot be null."); + + if (Array.Exists(accounts, static account => account is null)) + throw new JsonException("An RPC keyed-account list cannot contain null entries."); + + return accounts; + } + + private static T[] RequireNonNullEntries(T[]? values, string valueName) + where T : class + { + if (values is null) + throw new JsonException($"An RPC {valueName} cannot be null."); + + if (Array.Exists(values, static value => value is null)) + throw new JsonException($"An RPC {valueName} cannot contain null entries."); + + return values; + } + + private static T RequireContextValue(RpcContextValue result) + { + if (result.Value is null) + throw new JsonException("The RPC context wrapper carried null for a non-null value contract."); + + return result.Value; + } + + private async Task> ReadResponseBodyAsync(HttpContent content, CancellationToken cancellationToken) + { + var contentLength = content.Headers.ContentLength; + if (contentLength is { } declaredLength && declaredLength > _maximumResponseContentLength) + throw ResponseTooLarge(declaredLength); + + await using var stream = await content.ReadAsStreamAsync(cancellationToken); + var initialCapacity = contentLength is > 0 + ? Math.Min((int)contentLength.Value, 64 * 1024) + : Math.Min(16 * 1024, _maximumResponseContentLength); + using var body = new MemoryStream(initialCapacity); + var buffer = ArrayPool.Shared.Rent(64 * 1024); + + try + { + long total = 0; + while (true) + { + var remaining = _maximumResponseContentLength - total; + var requested = (int)Math.Min(buffer.Length, remaining + 1); + var read = await stream.ReadAsync( + buffer.AsMemory(0, requested), cancellationToken); + if (read == 0) + return body.GetBuffer().AsMemory(0, checked((int)total)); + + total += read; + if (total > _maximumResponseContentLength) + throw ResponseTooLarge(total); + + body.Write(buffer, 0, read); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } } + private HttpRequestException ResponseTooLarge(long receivedLength) + => new( + $"The JSON-RPC response body is {receivedLength} bytes, exceeding the configured " + + $"{_maximumResponseContentLength}-byte limit ({nameof(SolanaRpcOptions.MaximumResponseContentLength)})."); + // Validates the envelope and extracts the result in a single pass: a Utf8JsonReader walk checks // jsonrpc/id/error and records the span of the result value, which is then deserialized directly - // no intermediate JsonElement DOM of the (possibly multi-megabyte) result. - private static T DeserializeEnvelope(byte[] body, int requestId) + private static T DeserializeEnvelope(ReadOnlySpan body, int requestId, bool allowNullResult) { - var span = body.AsSpan(); + var span = body; // ReadFromJsonAsync tolerated a UTF-8 BOM; Utf8JsonReader rejects it. if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..]; @@ -1256,8 +2601,11 @@ private static T DeserializeEnvelope(byte[] body, int requestId) throw new JsonException("The JSON-RPC response is not a JSON object."); string? version = null; + var hasId = false; + var idIsNull = false; var idMatches = false; var hasResult = false; + var resultIsNull = false; var resultStart = 0; var resultLength = 0; RpcError? error = null; @@ -1268,13 +2616,37 @@ private static T DeserializeEnvelope(byte[] body, int requestId) { reader.Read(); hasResult = true; + resultIsNull = reader.TokenType == JsonTokenType.Null; resultStart = (int)reader.TokenStartIndex; reader.Skip(); resultLength = (int)reader.BytesConsumed - resultStart; } else if (reader.ValueTextEquals("error"u8)) { - error = JsonSerializer.Deserialize(ref reader, RpcJson.TypeInfo()); + reader.Read(); + if (reader.TokenType != JsonTokenType.Null) + { + using var errorDocument = JsonDocument.ParseValue(ref reader); + var errorElement = errorDocument.RootElement; + if (errorElement.ValueKind != JsonValueKind.Object || + !errorElement.TryGetProperty("code", out var codeElement) || + codeElement.ValueKind != JsonValueKind.Number || + !codeElement.TryGetInt32(out var code) || + !errorElement.TryGetProperty("message", out var messageElement) || + messageElement.ValueKind != JsonValueKind.String) + { + throw new RpcException(-1, "JSON-RPC response carried a malformed error object."); + } + + error = new RpcError + { + Code = code, + Message = messageElement.GetString()!, + Data = errorElement.TryGetProperty("data", out var dataElement) + ? dataElement.Clone() + : null + }; + } } else if (reader.ValueTextEquals("jsonrpc"u8)) { @@ -1285,6 +2657,8 @@ private static T DeserializeEnvelope(byte[] body, int requestId) else if (reader.ValueTextEquals("id"u8)) { reader.Read(); + hasId = true; + idIsNull = reader.TokenType == JsonTokenType.Null; idMatches = reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var id) && id == requestId; @@ -1301,18 +2675,33 @@ private static T DeserializeEnvelope(byte[] body, int requestId) if (reader.Read()) throw new JsonException("The JSON-RPC response carries trailing content."); - // The node's error is the most useful diagnostic there is, so it outranks envelope strictness: - // per JSON-RPC 2.0 an unprocessable request is answered with "id": null, and some gateways pad - // error responses with "result": null - neither may mask the real code and message. - if (error is not null) - throw new RpcException(error.Code, error.Message); if (version != "2.0") throw new RpcException(-1, "Invalid JSON-RPC response version."); + + // An unprocessable request legitimately carries "id": null. A numeric error id must still + // correlate to this request; otherwise a multiplexing proxy could surface another call's error. + if (error is not null) + { + if (!hasId || (!idIsNull && !idMatches)) + throw new RpcException(-1, "JSON-RPC response id did not match the request id."); + + if (hasResult && !resultIsNull) + throw new RpcException(-1, "JSON-RPC response contained both a non-null result and an error."); + + // Some gateways pad an error response with "result": null. Once version and correlation are + // valid, the node's concrete error remains the most useful diagnostic. + throw new RpcException(error.Code, error.Message, error.Data); + } + if (!idMatches) throw new RpcException(-1, "JSON-RPC response id did not match the request id."); if (!hasResult) throw new RpcException(-1, "JSON-RPC response contained neither a result nor an error."); - return JsonSerializer.Deserialize(span.Slice(resultStart, resultLength), RpcJson.TypeInfo())!; + var result = JsonSerializer.Deserialize(span.Slice(resultStart, resultLength), RpcJson.TypeInfo()); + if (!allowNullResult && result is null) + throw new JsonException("The JSON-RPC method returned null for a non-null result contract."); + + return result!; } } diff --git a/src/SolSharp.Rpc/SolanaRpcOptions.cs b/src/SolSharp.Rpc/SolanaRpcOptions.cs index 91af7c1..581c375 100644 --- a/src/SolSharp.Rpc/SolanaRpcOptions.cs +++ b/src/SolSharp.Rpc/SolanaRpcOptions.cs @@ -5,8 +5,17 @@ namespace SolSharp.Rpc; /// Configuration for . public sealed class SolanaRpcOptions { + /// The default maximum HTTP response body size: 128 MiB. + public const int DefaultMaximumResponseContentLength = 128 * 1024 * 1024; + /// The JSON-RPC HTTP endpoint the client posts to. [Required(AllowEmptyStrings = false)] [Url] public string Endpoint { get; set; } = "https://api.mainnet-beta.solana.com"; + + /// + /// Maximum decoded HTTP response body size, in bytes. Defaults to 128 MiB so large block and program-account + /// responses remain usable while an unbounded or unexpectedly large response cannot exhaust process memory. + /// + public int MaximumResponseContentLength { get; set; } = DefaultMaximumResponseContentLength; } diff --git a/src/SolSharp.Rpc/Streaming/BlockNotification.cs b/src/SolSharp.Rpc/Streaming/BlockNotification.cs index 8c99c7b..67e63bf 100644 --- a/src/SolSharp.Rpc/Streaming/BlockNotification.cs +++ b/src/SolSharp.Rpc/Streaming/BlockNotification.cs @@ -9,21 +9,32 @@ namespace SolSharp.Rpc.Streaming; /// error when the block could not be produced. /// /// blockSubscribe -public sealed record BlockNotification +public sealed record BlockNotification : IJsonOnDeserialized { /// The slot this notification is for. [JsonPropertyName("slot")] + [JsonRequired] public ulong Slot { get; init; } /// The error that prevented the block from being produced, or null on success. [JsonPropertyName("err")] + [JsonRequired] public JsonElement? Err { get; init; } /// The produced block, or null when is set. [JsonPropertyName("block")] + [JsonRequired] public Block? Block { get; init; } /// True when the block could not be produced ( is present). [JsonIgnore] public bool IsError => Err is { ValueKind: not JsonValueKind.Null }; + + /// + public void OnDeserialized() + { + var hasError = Err is { ValueKind: not (JsonValueKind.Null or JsonValueKind.Undefined) }; + if ((Block is not null) == hasError) + throw new JsonException("A block notification must carry exactly one of block or error."); + } } diff --git a/src/SolSharp.Rpc/Streaming/ClientWebSocketConnection.cs b/src/SolSharp.Rpc/Streaming/ClientWebSocketConnection.cs index 66060f5..2b7d1fe 100644 --- a/src/SolSharp.Rpc/Streaming/ClientWebSocketConnection.cs +++ b/src/SolSharp.Rpc/Streaming/ClientWebSocketConnection.cs @@ -15,6 +15,14 @@ internal sealed class ClientWebSocketConnection : IWebSocketConnection private readonly IClientWebSocket _socket; private readonly int _maxMessageSizeBytes; private readonly TimeSpan _closeTimeout; + private readonly object _lifecycleGate = new(); + private readonly TaskCompletionSource _peerCloseReceived = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private Task? _disposeTask; + private bool _receiveActive; + private bool _closeOwnsReceive; + private bool _disposed; public ClientWebSocketConnection() : this(new ClientWebSocketAdapter(), DefaultMaxMessageSizeBytes, DefaultCloseTimeout) @@ -45,7 +53,29 @@ public Task ConnectAsync(Uri uri, CancellationToken cancellationToken) public ValueTask SendAsync(string text, CancellationToken cancellationToken) => _socket.SendAsync(Encoding.UTF8.GetBytes(text).AsMemory(), WebSocketMessageType.Text, endOfMessage: true, cancellationToken); - public async ValueTask ReceiveAsync(CancellationToken cancellationToken) + public ValueTask ReceiveAsync(CancellationToken cancellationToken) + { + lock (_lifecycleGate) + { + if (_disposed || (_disposeTask is not null && _closeOwnsReceive)) + { + return ValueTask.FromException( + new ObjectDisposedException(nameof(ClientWebSocketConnection))); + } + + if (_receiveActive) + { + return ValueTask.FromException( + new InvalidOperationException("Only one WebSocket receive may be active at a time.")); + } + + _receiveActive = true; + } + + return ReceiveCoreAsync(cancellationToken); + } + + private async ValueTask ReceiveCoreAsync(CancellationToken cancellationToken) { var buffer = ArrayPool.Shared.Rent(BufferSize); try @@ -56,10 +86,17 @@ public ValueTask SendAsync(string text, CancellationToken cancellationToken) var result = await _socket.ReceiveAsync(buffer.AsMemory(), cancellationToken); if (result.MessageType == WebSocketMessageType.Close) { - await CloseOutputSafelyAsync( - _socket.CloseStatus ?? WebSocketCloseStatus.NormalClosure, - _socket.CloseStatusDescription, - cancellationToken); + // When the peer initiated the close, acknowledge it here. If local disposal already + // sent our close frame, ManagedWebSocket is CloseSent/Closed and the handshake is done. + if (_socket.State == WebSocketState.CloseReceived) + { + await CloseOutputSafelyAsync( + _socket.CloseStatus ?? WebSocketCloseStatus.NormalClosure, + _socket.CloseStatusDescription, + cancellationToken); + } + + _peerCloseReceived.TrySetResult(); return null; } @@ -89,23 +126,85 @@ await CloseOutputSafelyAsync( return Encoding.UTF8.GetString(message.ToArray()); } + catch (Exception exception) + { + // A disposer waiting for the receive loop to consume the peer's close frame cannot make + // further progress after a receive failure; wake it so it can abort instead of waiting twice. + _peerCloseReceived.TrySetException(exception); + throw; + } finally { ArrayPool.Shared.Return(buffer); + lock (_lifecycleGate) + _receiveActive = false; } } - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived) + TaskCompletionSource completion; + bool receiveLoopOwnsPeerRead; + lock (_lifecycleGate) { - await CloseOutputSafelyAsync( - WebSocketCloseStatus.NormalClosure, - "bye", - CancellationToken.None); + if (_disposeTask is not null) + return new ValueTask(_disposeTask); + + completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _disposeTask = completion.Task; + receiveLoopOwnsPeerRead = _receiveActive; + _closeOwnsReceive = !receiveLoopOwnsPeerRead; } - _socket.Dispose(); + _ = DisposeCoreAsync(completion, receiveLoopOwnsPeerRead); + return new ValueTask(completion.Task); + } + + private async Task DisposeCoreAsync(TaskCompletionSource completion, bool receiveLoopOwnsPeerRead) + { + using var timeout = new CancellationTokenSource(); + timeout.CancelAfter(_closeTimeout); + try + { + switch (_socket.State) + { + case WebSocketState.Open when receiveLoopOwnsPeerRead: + // CloseOutputAsync only sends our half of the handshake. The already-active receive + // loop owns the one permitted receive and completes the other half below. + await _socket.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, "bye", timeout.Token); + await _peerCloseReceived.Task.WaitAsync(timeout.Token); + break; + + case WebSocketState.CloseReceived when receiveLoopOwnsPeerRead: + case WebSocketState.CloseSent when receiveLoopOwnsPeerRead: + await _peerCloseReceived.Task.WaitAsync(timeout.Token); + break; + + case WebSocketState.Open: + case WebSocketState.CloseSent: + // With no external receive in flight, let ClientWebSocket own both halves. + await _socket.CloseAsync( + WebSocketCloseStatus.NormalClosure, "bye", timeout.Token); + break; + + case WebSocketState.CloseReceived: + await _socket.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, "bye", timeout.Token); + break; + } + } + catch + { + _socket.Abort(); + } + finally + { + _socket.Dispose(); + lock (_lifecycleGate) + _disposed = true; + completion.TrySetResult(); + } } private async Task CloseOutputSafelyAsync( diff --git a/src/SolSharp.Rpc/Streaming/IClientWebSocket.cs b/src/SolSharp.Rpc/Streaming/IClientWebSocket.cs index 4fe448c..9ff0334 100644 --- a/src/SolSharp.Rpc/Streaming/IClientWebSocket.cs +++ b/src/SolSharp.Rpc/Streaming/IClientWebSocket.cs @@ -26,6 +26,11 @@ Task CloseOutputAsync( string? statusDescription, CancellationToken cancellationToken); + Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken); + void Abort(); } @@ -61,6 +66,12 @@ public Task CloseOutputAsync( CancellationToken cancellationToken) => _socket.CloseOutputAsync(closeStatus, statusDescription, cancellationToken); + public Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + => _socket.CloseAsync(closeStatus, statusDescription, cancellationToken); + public void Abort() => _socket.Abort(); public void Dispose() => _socket.Dispose(); diff --git a/src/SolSharp.Rpc/Streaming/LogInfo.cs b/src/SolSharp.Rpc/Streaming/LogInfo.cs index 8c84828..cbc69e9 100644 --- a/src/SolSharp.Rpc/Streaming/LogInfo.cs +++ b/src/SolSharp.Rpc/Streaming/LogInfo.cs @@ -7,17 +7,37 @@ namespace SolSharp.Rpc.Streaming; /// logsSubscribe public sealed record LogInfo { + private string? _signature; + private IReadOnlyList? _logs; + /// The transaction signature these logs belong to. [JsonPropertyName("signature")] - public string Signature { get; init; } = string.Empty; + [JsonRequired] + public string Signature + { + get => _signature ?? throw new InvalidOperationException("The log signature has not been initialized."); + init => _signature = value ?? throw new JsonException("A logs notification must carry a signature."); + } /// The transaction error, or null if it succeeded. [JsonPropertyName("err")] + [JsonRequired] public JsonElement? Err { get; init; } /// The log lines emitted by the transaction. [JsonPropertyName("logs")] - public IReadOnlyList Logs { get; init; } = []; + [JsonRequired] + public IReadOnlyList Logs + { + get => _logs ?? throw new InvalidOperationException("The log lines have not been initialized."); + init + { + if (value is null || value.Any(static entry => entry is null)) + throw new JsonException("A logs notification must carry only non-null log strings."); + + _logs = value; + } + } /// True when the transaction failed ( is present). [JsonIgnore] diff --git a/src/SolSharp.Rpc/Streaming/ParsedBlockNotification.cs b/src/SolSharp.Rpc/Streaming/ParsedBlockNotification.cs index 3452a0f..e19a618 100644 --- a/src/SolSharp.Rpc/Streaming/ParsedBlockNotification.cs +++ b/src/SolSharp.Rpc/Streaming/ParsedBlockNotification.cs @@ -9,21 +9,32 @@ namespace SolSharp.Rpc.Streaming; /// , or just the slot and an error when the block could not be produced. /// /// blockSubscribe -public sealed record ParsedBlockNotification +public sealed record ParsedBlockNotification : IJsonOnDeserialized { /// The slot this notification is for. [JsonPropertyName("slot")] + [JsonRequired] public ulong Slot { get; init; } /// The error that prevented the block from being produced, or null on success. [JsonPropertyName("err")] + [JsonRequired] public JsonElement? Err { get; init; } /// The produced block with parsed transactions, or null when is set. [JsonPropertyName("block")] + [JsonRequired] public ParsedBlock? Block { get; init; } /// True when the block could not be produced ( is present). [JsonIgnore] public bool IsError => Err is { ValueKind: not JsonValueKind.Null }; + + /// + public void OnDeserialized() + { + var hasError = Err is { ValueKind: not (JsonValueKind.Null or JsonValueKind.Undefined) }; + if ((Block is not null) == hasError) + throw new JsonException("A parsed-block notification must carry exactly one of block or error."); + } } diff --git a/src/SolSharp.Rpc/Streaming/RawBlockNotification.cs b/src/SolSharp.Rpc/Streaming/RawBlockNotification.cs new file mode 100644 index 0000000..2ac4ba4 --- /dev/null +++ b/src/SolSharp.Rpc/Streaming/RawBlockNotification.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SolSharp.Rpc.Streaming; + +/// +/// A configurable blockSubscribe update whose block body is retained as JSON because its schema +/// depends on the requested encoding and transaction detail level. +/// +/// blockSubscribe +public sealed record RawBlockNotification : IJsonOnDeserialized +{ + /// The slot this notification is for. + [JsonPropertyName("slot")] + [JsonRequired] + public ulong Slot { get; init; } + + /// The error that prevented the block from being produced, or null on success. + [JsonPropertyName("err")] + [JsonRequired] + public JsonElement? Err { get; init; } + + /// The configured block JSON, or null when is set. + [JsonPropertyName("block")] + [JsonRequired] + public JsonElement? Block { get; init; } + + /// True when the block could not be produced. + [JsonIgnore] + public bool IsError => Err is { ValueKind: not JsonValueKind.Null }; + + /// + public void OnDeserialized() + { + var hasError = Err is { ValueKind: not (JsonValueKind.Null or JsonValueKind.Undefined) }; + var hasBlock = Block is { ValueKind: not (JsonValueKind.Null or JsonValueKind.Undefined) }; + if (hasBlock == hasError) + throw new JsonException("A raw block notification must carry exactly one of block or error."); + } +} diff --git a/src/SolSharp.Rpc/Streaming/SignatureNotification.cs b/src/SolSharp.Rpc/Streaming/SignatureNotification.cs index 4ee9208..79c722c 100644 --- a/src/SolSharp.Rpc/Streaming/SignatureNotification.cs +++ b/src/SolSharp.Rpc/Streaming/SignatureNotification.cs @@ -4,17 +4,104 @@ namespace SolSharp.Rpc.Streaming; /// -/// A signatureSubscribe notification: delivered once, when the subscribed transaction reaches the -/// requested commitment. The node unsubscribes automatically after sending it. +/// A signatureSubscribe notification: normally the final processed result, with an optional earlier +/// received event when requested. The node unsubscribes automatically after the final result. /// /// signatureSubscribe +[JsonConverter(typeof(SignatureNotificationJsonConverter))] public sealed record SignatureNotification { + /// Whether this is the early received event or the final processed result. + public SignatureNotificationKind Kind { get; init; } + /// The transaction error, or null if it succeeded. - [JsonPropertyName("err")] public JsonElement? Err { get; init; } + /// True for the optional early "receivedSignature" event. + [JsonIgnore] + public bool IsReceived => Kind == SignatureNotificationKind.Received; + + /// True for the final processed-signature object. + [JsonIgnore] + public bool IsFinal => Kind == SignatureNotificationKind.Processed; + /// True when the transaction failed ( is present). [JsonIgnore] - public bool IsError => Err is { ValueKind: not JsonValueKind.Null }; + public bool IsError => IsFinal && Err is { ValueKind: not JsonValueKind.Null }; +} + +/// The two wire variants emitted by signatureSubscribe. +public enum SignatureNotificationKind +{ + /// The final { err } processed-signature result. + Processed, + + /// The early "receivedSignature" event. + Received +} + +/// Converts the untagged string-or-object signature notification union. +public sealed class SignatureNotificationJsonConverter : JsonConverter +{ + /// + public override bool HandleNull => true; + + /// + public override SignatureNotification Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var root = document.RootElement; + if (root.ValueKind == JsonValueKind.String) + { + if (root.GetString() != "receivedSignature") + throw new JsonException("Expected the receivedSignature event."); + + return new SignatureNotification { Kind = SignatureNotificationKind.Received }; + } + + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("err", out var error)) + throw new JsonException("Expected a receivedSignature string or a processed { err } object."); + + return new SignatureNotification + { + Kind = SignatureNotificationKind.Processed, + Err = error.Clone() + }; + } + + /// + public override void Write( + Utf8JsonWriter writer, + SignatureNotification value, + JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + switch (value.Kind) + { + case SignatureNotificationKind.Processed: + writer.WriteStartObject(); + writer.WritePropertyName("err"); + if (value.Err is { } error) + error.WriteTo(writer); + else + writer.WriteNullValue(); + writer.WriteEndObject(); + break; + case SignatureNotificationKind.Received when value.Err is null: + writer.WriteStringValue("receivedSignature"); + break; + case SignatureNotificationKind.Received: + throw new JsonException("A receivedSignature event cannot carry an error payload."); + default: + throw new JsonException($"Unknown signature notification kind '{value.Kind}'."); + } + } } diff --git a/src/SolSharp.Rpc/Streaming/SlotInfo.cs b/src/SolSharp.Rpc/Streaming/SlotInfo.cs index fc0f5a0..ffd31bf 100644 --- a/src/SolSharp.Rpc/Streaming/SlotInfo.cs +++ b/src/SolSharp.Rpc/Streaming/SlotInfo.cs @@ -8,13 +8,16 @@ public sealed record SlotInfo { /// The parent slot. [JsonPropertyName("parent")] + [JsonRequired] public ulong Parent { get; init; } /// The current root slot. [JsonPropertyName("root")] + [JsonRequired] public ulong Root { get; init; } /// The newly set slot. [JsonPropertyName("slot")] + [JsonRequired] public ulong Slot { get; init; } } diff --git a/src/SolSharp.Rpc/Streaming/SlotsUpdate.cs b/src/SolSharp.Rpc/Streaming/SlotsUpdate.cs index bbeabf8..4440b12 100644 --- a/src/SolSharp.Rpc/Streaming/SlotsUpdate.cs +++ b/src/SolSharp.Rpc/Streaming/SlotsUpdate.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; namespace SolSharp.Rpc.Streaming; @@ -7,23 +8,32 @@ namespace SolSharp.Rpc.Streaming; /// slot moves through (see ). /// /// slotsUpdatesSubscribe -public sealed record SlotsUpdate +public sealed record SlotsUpdate : IJsonOnDeserialized { + private string? _type; + /// The slot the update is about. [JsonPropertyName("slot")] + [JsonRequired] public ulong Slot { get; init; } /// /// The update type: firstShredReceived, completed, createdBank, frozen, - /// dead, optimisticConfirmation, or root. Kept as a string so new node-side - /// stages do not break deserialization. + /// dead, optimisticConfirmation, or root. The pinned closed set is validated + /// after deserialization; the string preserves the exact wire name. /// [JsonPropertyName("type")] - public string Type { get; init; } = string.Empty; + [JsonRequired] + public string Type + { + get => _type ?? throw new InvalidOperationException("The slot-update type has not been initialized."); + init => _type = value ?? throw new JsonException("A slot update must carry its type."); + } /// The update's Unix timestamp in milliseconds. [JsonPropertyName("timestamp")] - public long Timestamp { get; init; } + [JsonRequired] + public ulong Timestamp { get; init; } /// The parent slot; only present on createdBank updates. [JsonPropertyName("parent")] @@ -36,6 +46,33 @@ public sealed record SlotsUpdate /// Transaction counts for the slot; only present on frozen updates. [JsonPropertyName("stats")] public SlotsUpdateStats? Stats { get; init; } + + /// + public void OnDeserialized() + { + switch (Type) + { + case "firstShredReceived": + case "completed": + case "optimisticConfirmation": + case "root": + break; + case "createdBank" when Parent is not null: + break; + case "frozen" when Stats is not null: + break; + case "dead" when Error is not null: + break; + case "createdBank": + throw new JsonException("A createdBank slot update must carry its parent."); + case "frozen": + throw new JsonException("A frozen slot update must carry transaction statistics."); + case "dead": + throw new JsonException("A dead slot update must carry its error."); + default: + throw new JsonException($"Unknown slot update type '{Type}'."); + } + } } /// The per-slot transaction counts attached to a frozen . @@ -43,17 +80,21 @@ public sealed record SlotsUpdateStats { /// The number of transaction entries in the slot. [JsonPropertyName("numTransactionEntries")] + [JsonRequired] public ulong NumTransactionEntries { get; init; } /// The number of successful transactions in the slot. [JsonPropertyName("numSuccessfulTransactions")] + [JsonRequired] public ulong NumSuccessfulTransactions { get; init; } /// The number of failed transactions in the slot. [JsonPropertyName("numFailedTransactions")] + [JsonRequired] public ulong NumFailedTransactions { get; init; } /// The largest number of transactions in a single entry. [JsonPropertyName("maxTransactionsPerEntry")] + [JsonRequired] public ulong MaxTransactionsPerEntry { get; init; } } diff --git a/src/SolSharp.Rpc/Streaming/SolanaWsClient.cs b/src/SolSharp.Rpc/Streaming/SolanaWsClient.cs index 967fcd5..b963b87 100644 --- a/src/SolSharp.Rpc/Streaming/SolanaWsClient.cs +++ b/src/SolSharp.Rpc/Streaming/SolanaWsClient.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization.Metadata; @@ -26,20 +25,54 @@ public sealed class SolanaWsClient : IAsyncDisposable { private readonly Func _connectionFactory; private readonly SolanaWsClientOptions _options; - private readonly ConcurrentDictionary _pending = new(); - private readonly ConcurrentDictionary _active = new(); - private readonly ConcurrentDictionary _byServerId = new(); + private readonly object _stateGate = new(); + private readonly Dictionary _pending = []; + private readonly Dictionary _active = []; + private readonly Dictionary<(long Generation, ulong ServerId), Subscription> _byServerId = []; private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly CancellationTokenSource _lifetimeCts = new(); private readonly ILogger _logger; - private IWebSocketConnection? _connection; + private ConnectionEpoch? _connection; + private ConnectionEpoch? _connecting; private Uri? _endpoint; private int _nextRequestId; private long _nextLocalId; private long _connectionGeneration; private Task? _runLoop; - private bool _disposed; + private Task? _connectTask; + private Task? _disposeTask; + private ClientPhase _phase; + private int _sendOperationCount; + private TaskCompletionSource? _sendOperationsDrained; + private int _cancellationRegistrationCount; + + internal int RetainedCancellationRegistrationCount + { + get + { + lock (_stateGate) + return _cancellationRegistrationCount; + } + } + + internal int RetainedPendingSubscriptionReferenceCount + { + get + { + lock (_stateGate) + return _pending.Values.Count(pending => pending.Subscription is not null); + } + } + + internal int RetainedAcknowledgementTombstoneCount + { + get + { + lock (_stateGate) + return _pending.Values.Count(pending => pending.State == PendingState.Abandoned); + } + } /// Creates a client over a real with default options. /// Optional factory for connection/reconnection diagnostics; no logging when null. @@ -65,8 +98,20 @@ internal SolanaWsClient(Func connectionFactory, SolanaWsCl throw new ArgumentOutOfRangeException(nameof(options), "Maximum message size must be positive."); if (options.SubscriptionBufferCapacity <= 0) throw new ArgumentOutOfRangeException(nameof(options), "Subscription buffer capacity must be positive."); + if (options.ReconnectInitialDelay < TimeSpan.Zero || options.ReconnectInitialDelay > MaximumTimerDuration) + throw new ArgumentOutOfRangeException(nameof(options), "Initial reconnect delay must be non-negative and supported by a timer."); + if (options.ReconnectMaxDelay < options.ReconnectInitialDelay || options.ReconnectMaxDelay > MaximumTimerDuration) + throw new ArgumentOutOfRangeException(nameof(options), "Maximum reconnect delay must be at least the initial delay and supported by a timer."); + if (options.MaxReconnectAttempts < 0) + throw new ArgumentOutOfRangeException(nameof(options), "Maximum reconnect attempts cannot be negative."); + if (options.SubscriptionAckTimeout <= TimeSpan.Zero || options.SubscriptionAckTimeout > MaximumTimerDuration) + throw new ArgumentOutOfRangeException(nameof(options), "Subscription acknowledgement timeout must be positive and finite."); + if (options.MaxPendingSubscriptionRequests <= 0) + throw new ArgumentOutOfRangeException(nameof(options), "Maximum pending subscription requests must be positive."); if (options.ReceiveTimeout != Timeout.InfiniteTimeSpan && options.ReceiveTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(options), "Receive timeout must be positive or infinite."); + if (options.ReceiveTimeout > MaximumTimerDuration) + throw new ArgumentOutOfRangeException(nameof(options), "Receive timeout is too large for a timer."); _connectionFactory = connectionFactory; _options = options; @@ -89,17 +134,88 @@ internal SolanaWsClient(IWebSocketConnection connection) /// The was cancelled. /// The client is already connected. /// The client has been disposed. - public async Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default) + public Task ConnectAsync(Uri endpoint, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endpoint); + + TaskCompletionSource completion; + lock (_stateGate) + { + if (_phase is ClientPhase.Disposing or ClientPhase.Disposed) + return Task.FromException(new ObjectDisposedException(nameof(SolanaWsClient))); + if (_phase != ClientPhase.New) + return Task.FromException( + new InvalidOperationException("The client is already connected; create one client per connection.")); + + _phase = ClientPhase.Connecting; + _endpoint = endpoint; + completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _connectTask = completion.Task; + } + + _ = ConnectInitialAsync(endpoint, completion, cancellationToken); + return completion.Task; + } + + private async Task ConnectInitialAsync( + Uri endpoint, + TaskCompletionSource completion, + CancellationToken cancellationToken) { - ObjectDisposedException.ThrowIf(_disposed, this); - if (_runLoop is not null) - throw new InvalidOperationException("The client is already connected; create one client per connection."); + ConnectionEpoch? epoch = null; + try + { + epoch = CreateConnectionEpoch(); + lock (_stateGate) + { + ObjectDisposedException.ThrowIf(_phase != ClientPhase.Connecting, this); + + _connecting = epoch; + } + + using var linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, _lifetimeCts.Token, epoch.Token); + await epoch.Connection.ConnectAsync(endpoint, linked.Token); + + lock (_stateGate) + { + ObjectDisposedException.ThrowIf( + _phase != ClientPhase.Connecting || !ReferenceEquals(_connecting, epoch), + this); + + _connecting = null; + _connection = epoch; + _phase = ClientPhase.Connected; + _runLoop = Task.Run(() => RunAsync(epoch, _lifetimeCts.Token), CancellationToken.None); + } + + completion.TrySetResult(); + } + catch (Exception exception) + { + lock (_stateGate) + { + if (ReferenceEquals(_connecting, epoch)) + _connecting = null; + if (_phase == ClientPhase.Connecting) + _phase = ClientPhase.New; + } + + if (epoch is not null) + await epoch.DisposeOnceAsync(); - _endpoint = endpoint; - _connection = _connectionFactory(); - await _connection.ConnectAsync(endpoint, cancellationToken); - Interlocked.Increment(ref _connectionGeneration); - _runLoop = Task.Run(() => RunAsync(_lifetimeCts.Token)); + if (exception is OperationCanceledException canceled) + { + if (cancellationToken.IsCancellationRequested) + completion.TrySetCanceled(cancellationToken); + else if (_lifetimeCts.IsCancellationRequested) + completion.TrySetException(new ObjectDisposedException(nameof(SolanaWsClient), canceled.Message)); + else + completion.TrySetCanceled(canceled.CancellationToken); + } + else + completion.TrySetException(exception); + } } /// @@ -167,9 +283,38 @@ public async Task>> SubscribeLogsAsync( { var sink = CreateSubscriptionSink>(); object[] parameters = [new LogsFilter { Mentions = [program] }, new CommitmentConfig { Commitment = commitment }]; - var subscription = await RegisterAsync("logsSubscribe", parameters, "logsUnsubscribe", sink, cancellationToken); + await RegisterAsync("logsSubscribe", parameters, "logsUnsubscribe", sink, cancellationToken); + return sink.Reader; + } + + /// + /// Subscribes to logs using the full upstream all, allWithVotes, or single-address + /// mentions filter union. + /// + /// The log subscription filter. + /// The commitment level at which logs are delivered. + /// Unsubscribes and completes the channel when cancelled. + /// A channel reader of context-wrapped log notifications. + /// is null. + /// The node rejected the subscription, or the connection closed. + /// The was cancelled before acknowledgement. + public async Task>> SubscribeLogsWithFilterAsync( + LogsSubscriptionFilter filter, + Commitment commitment = Commitment.Confirmed, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(filter); + object filterPayload = filter.Kind switch + { + LogsSubscriptionFilterKind.All => "all", + LogsSubscriptionFilterKind.AllWithVotes => "allWithVotes", + LogsSubscriptionFilterKind.Mentions => new LogsFilter { Mentions = [filter.Mention!.Value] }, + _ => throw new ArgumentOutOfRangeException(nameof(filter), "Unknown log subscription filter kind.") + }; - cancellationToken.Register(() => Cancel(subscription, cancellationToken)); + var sink = CreateSubscriptionSink>(); + object[] parameters = [filterPayload, new CommitmentConfig { Commitment = commitment }]; + await RegisterAsync("logsSubscribe", parameters, "logsUnsubscribe", sink, cancellationToken); return sink.Reader; } @@ -192,9 +337,39 @@ public async Task>> SubscribeAccountA { var sink = CreateSubscriptionSink>(); object[] parameters = [account, new AccountInfoConfig { Encoding = "base64", Commitment = commitment }]; - var subscription = await RegisterAsync("accountSubscribe", parameters, "accountUnsubscribe", sink, cancellationToken); + await RegisterAsync("accountSubscribe", parameters, "accountUnsubscribe", sink, cancellationToken); + return sink.Reader; + } - cancellationToken.Register(() => Cancel(subscription, cancellationToken)); + /// + /// Subscribes to an account with the complete set of configuration fields that pinned Agave actually + /// applies. The returned account-data union preserves binary, base58, base64, jsonParsed (including its + /// binary fallback), and base64+zstd responses without guessing a branch. + /// + /// The account to watch. + /// The effective account-subscription configuration. + /// Unsubscribes and completes the channel when cancelled. + /// A channel reader of context-wrapped accounts with exact upstream data branches. + /// is null. + /// The node rejected the subscription, or the connection closed. + /// The was cancelled before acknowledgement. + public async Task>> SubscribeAccountWithOptionsAsync( + PublicKey account, + AccountSubscriptionOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var sink = CreateSubscriptionSink>(); + object[] parameters = + [ + account, + new AccountInfoConfig + { + Encoding = options.Encoding is { } encoding ? RpcWireNames.AccountEncoding(encoding) : null, + Commitment = options.Commitment + } + ]; + await RegisterAsync("accountSubscribe", parameters, "accountUnsubscribe", sink, cancellationToken); return sink.Reader; } @@ -216,9 +391,7 @@ public async Task>> SubscribePa { var sink = CreateSubscriptionSink>(); object[] parameters = [account, new AccountInfoConfig { Encoding = "jsonParsed", Commitment = commitment }]; - var subscription = await RegisterAsync("accountSubscribe", parameters, "accountUnsubscribe", sink, cancellationToken); - - cancellationToken.Register(() => Cancel(subscription, cancellationToken)); + await RegisterAsync("accountSubscribe", parameters, "accountUnsubscribe", sink, cancellationToken); return sink.Reader; } @@ -253,9 +426,68 @@ public async Task>> SubscribeProgr Filters = filters?.Select(filter => filter.Payload).ToArray() } ]; - var subscription = await RegisterAsync("programSubscribe", parameters, "programUnsubscribe", sink, cancellationToken); + await RegisterAsync("programSubscribe", parameters, "programUnsubscribe", sink, cancellationToken); + return sink.Reader; + } + + /// + /// Subscribes to a program with the complete set of configuration fields that pinned Agave actually + /// applies. The returned account-data union preserves every supported encoding branch exactly. + /// + /// The owning program to watch. + /// The effective program-subscription configuration. + /// Unsubscribes and completes the channel when cancelled. + /// A channel reader of context-wrapped program accounts with exact upstream data branches. + /// is null. + /// The node rejected the subscription, or the connection closed. + /// The was cancelled before acknowledgement. + public async Task>> SubscribeProgramWithOptionsAsync( + PublicKey program, + ProgramSubscriptionOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + var sink = CreateSubscriptionSink>(); + object[] parameters = + [ + program, + new ProgramAccountsConfig + { + Encoding = options.Encoding is { } encoding ? RpcWireNames.AccountEncoding(encoding) : null, + Commitment = options.Commitment, + Filters = options.Filters?.Select(filter => filter.Payload).ToArray() + } + ]; + await RegisterAsync("programSubscribe", parameters, "programUnsubscribe", sink, cancellationToken); + return sink.Reader; + } - cancellationToken.Register(() => Cancel(subscription, cancellationToken)); + /// Subscribes to program-account changes decoded by the node with jsonParsed encoding. + /// The owning program to watch. + /// The commitment level at which changes are delivered. + /// Filters every delivered account must satisfy; none when null. + /// Unsubscribes and completes the channel when cancelled. + /// A channel reader of context-wrapped parsed program accounts. + /// The node rejected the subscription, or the connection closed. + /// The was cancelled before acknowledgement. + public async Task>> SubscribeParsedProgramAsync( + PublicKey program, + Commitment commitment = Commitment.Confirmed, + IReadOnlyList? filters = null, + CancellationToken cancellationToken = default) + { + var sink = CreateSubscriptionSink>(); + object[] parameters = + [ + program, + new ProgramAccountsConfig + { + Encoding = "jsonParsed", + Commitment = commitment, + Filters = filters?.Select(filter => filter.Payload).ToArray() + } + ]; + await RegisterAsync("programSubscribe", parameters, "programUnsubscribe", sink, cancellationToken); return sink.Reader; } @@ -273,7 +505,24 @@ public async Task>> SubscribeProgr public Task>> SubscribeBlocksAsync( Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) - => SubscribeBlocksCoreAsync("all", commitment, cancellationToken); + => SubscribeBlocksCoreAsync("all", commitment, 0, cancellationToken); + + /// + /// Subscribes to every new signatures-only block while explicitly opting into a newer numeric transaction + /// version. The node must have block subscriptions enabled. Cancelling + /// unsubscribes and completes the channel. + /// + /// The highest numeric transaction version the caller accepts. + /// The commitment level to query at. + /// Unsubscribes and completes the channel when cancelled. + /// A channel reader of block notifications, each carrying its slot context and the produced block. + /// The node rejected the subscription, or the connection closed. + /// The was cancelled before the subscription was confirmed. + public Task>> SubscribeBlocksWithMaxVersionAsync( + byte maxSupportedTransactionVersion, + Commitment commitment = Commitment.Confirmed, + CancellationToken cancellationToken = default) + => SubscribeBlocksCoreAsync("all", commitment, maxSupportedTransactionVersion, cancellationToken); /// /// Subscribes to new blocks that mention , delivered through a @@ -293,11 +542,35 @@ public Task>> SubscribeBlocksAs Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) => SubscribeBlocksCoreAsync( - new BlockSubscribeFilter { MentionsAccountOrProgram = mentionsAccountOrProgram }, commitment, cancellationToken); + new BlockSubscribeFilter { MentionsAccountOrProgram = mentionsAccountOrProgram }, commitment, 0, cancellationToken); + + /// + /// Subscribes to signatures-only blocks that mention an account or program while explicitly opting into a + /// newer numeric transaction version. Cancelling unsubscribes and + /// completes the channel. + /// + /// The account or program a block must mention to be delivered. + /// The highest numeric transaction version the caller accepts. + /// The commitment level to query at. + /// Unsubscribes and completes the channel when cancelled. + /// A channel reader of block notifications, each carrying its slot context and the produced block. + /// The node rejected the subscription, or the connection closed. + /// The was cancelled before the subscription was confirmed. + public Task>> SubscribeBlocksWithMaxVersionAsync( + PublicKey mentionsAccountOrProgram, + byte maxSupportedTransactionVersion, + Commitment commitment = Commitment.Confirmed, + CancellationToken cancellationToken = default) + => SubscribeBlocksCoreAsync( + new BlockSubscribeFilter { MentionsAccountOrProgram = mentionsAccountOrProgram }, + commitment, + maxSupportedTransactionVersion, + cancellationToken); private async Task>> SubscribeBlocksCoreAsync( object filter, Commitment commitment, + byte maxSupportedTransactionVersion, CancellationToken cancellationToken) { var sink = CreateSubscriptionSink>(); @@ -310,12 +583,54 @@ private async Task>> SubscribeB Encoding = "json", TransactionDetails = "signatures", ShowRewards = false, - MaxSupportedTransactionVersion = 0 + MaxSupportedTransactionVersion = maxSupportedTransactionVersion } ]; - var subscription = await RegisterAsync("blockSubscribe", parameters, "blockUnsubscribe", sink, cancellationToken); + await RegisterAsync("blockSubscribe", parameters, "blockUnsubscribe", sink, cancellationToken); + return sink.Reader; + } + + /// + /// Subscribes to blocks using the exact upstream filter, encoding, transaction-details, rewards, + /// commitment, and transaction-version configuration. The block body remains JSON because those choices + /// change its schema. + /// + /// All blocks or blocks mentioning one account or program. + /// The exact upstream block subscription configuration. + /// Unsubscribes and completes the channel when cancelled. + /// A channel reader of context-wrapped configurable block updates. + /// or is null. + /// The node rejected the subscription, or the connection closed. + /// The was cancelled before acknowledgement. + public async Task>> SubscribeBlocksWithOptionsAsync( + BlockSubscriptionFilter filter, + BlockSubscriptionOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(options); - cancellationToken.Register(() => Cancel(subscription, cancellationToken)); + object filterPayload = filter.Mention is { } mention + ? new BlockSubscribeFilter { MentionsAccountOrProgram = mention } + : "all"; + var sink = CreateSubscriptionSink>(); + object[] parameters = + [ + filterPayload, + new BlockSubscribeConfig + { + Commitment = options.Commitment, + Encoding = options.Encoding is { } encoding + ? RpcWireNames.TransactionEncoding(encoding) + : null, + TransactionDetails = options.TransactionDetails is { } details + ? RpcWireNames.TransactionDetails(details) + : null, + ShowRewards = options.ShowRewards, + MaxSupportedTransactionVersion = options.MaxSupportedTransactionVersion + } + ]; + await RegisterAsync("blockSubscribe", parameters, "blockUnsubscribe", sink, cancellationToken); return sink.Reader; } @@ -334,7 +649,25 @@ private async Task>> SubscribeB public Task>> SubscribeParsedBlocksAsync( Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) - => SubscribeParsedBlocksCoreAsync("all", commitment, cancellationToken); + => SubscribeParsedBlocksCoreAsync("all", commitment, 0, cancellationToken); + + /// + /// Subscribes to every new node-decoded block while explicitly opting into a newer numeric transaction + /// version. V1 messages expose their execution settings on + /// . Cancelling + /// unsubscribes and completes the channel. + /// + /// The highest numeric transaction version the caller accepts. + /// The commitment level to query at. + /// Unsubscribes and completes the channel when cancelled. + /// A channel reader of parsed-block notifications, each carrying its slot context and the produced block. + /// The node rejected the subscription, or the connection closed. + /// The was cancelled before the subscription was confirmed. + public Task>> SubscribeParsedBlocksWithMaxVersionAsync( + byte maxSupportedTransactionVersion, + Commitment commitment = Commitment.Confirmed, + CancellationToken cancellationToken = default) + => SubscribeParsedBlocksCoreAsync("all", commitment, maxSupportedTransactionVersion, cancellationToken); /// /// Subscribes to new blocks that mention , with their @@ -354,11 +687,35 @@ public Task>> SubscribePa Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) => SubscribeParsedBlocksCoreAsync( - new BlockSubscribeFilter { MentionsAccountOrProgram = mentionsAccountOrProgram }, commitment, cancellationToken); + new BlockSubscribeFilter { MentionsAccountOrProgram = mentionsAccountOrProgram }, commitment, 0, cancellationToken); + + /// + /// Subscribes to node-decoded blocks that mention an account or program while explicitly opting into a + /// newer numeric transaction version. V1 messages expose their execution settings on + /// . + /// + /// The account or program a block must mention to be delivered. + /// The highest numeric transaction version the caller accepts. + /// The commitment level to query at. + /// Unsubscribes and completes the channel when cancelled. + /// A channel reader of parsed-block notifications, each carrying its slot context and the produced block. + /// The node rejected the subscription, or the connection closed. + /// The was cancelled before the subscription was confirmed. + public Task>> SubscribeParsedBlocksWithMaxVersionAsync( + PublicKey mentionsAccountOrProgram, + byte maxSupportedTransactionVersion, + Commitment commitment = Commitment.Confirmed, + CancellationToken cancellationToken = default) + => SubscribeParsedBlocksCoreAsync( + new BlockSubscribeFilter { MentionsAccountOrProgram = mentionsAccountOrProgram }, + commitment, + maxSupportedTransactionVersion, + cancellationToken); private async Task>> SubscribeParsedBlocksCoreAsync( object filter, Commitment commitment, + byte maxSupportedTransactionVersion, CancellationToken cancellationToken) { var sink = CreateSubscriptionSink>(); @@ -371,12 +728,10 @@ private async Task>> Subs Encoding = "jsonParsed", TransactionDetails = "full", ShowRewards = false, - MaxSupportedTransactionVersion = 0 + MaxSupportedTransactionVersion = maxSupportedTransactionVersion } ]; - var subscription = await RegisterAsync("blockSubscribe", parameters, "blockUnsubscribe", sink, cancellationToken); - - cancellationToken.Register(() => Cancel(subscription, cancellationToken)); + await RegisterAsync("blockSubscribe", parameters, "blockUnsubscribe", sink, cancellationToken); return sink.Reader; } @@ -396,12 +751,61 @@ public async Task>> Subscri string signature, Commitment commitment = Commitment.Confirmed, CancellationToken cancellationToken = default) + => await SubscribeSignatureCoreAsync( + signature, + commitment, + enableReceivedNotification: null, + cancellationToken); + + /// + /// Subscribes to a signature with optional early receipt notification. When enabled, the channel first + /// receives and remains active until the final + /// result. + /// + /// The transaction signature (base58) to watch. + /// Commitment and early-notification options. + /// Unsubscribes and completes the channel when cancelled. + /// A channel reader that yields the received event when requested and then the final result. + /// is null. + /// The node rejected the subscription, or the connection closed. + /// The was cancelled before acknowledgement. + public Task>> SubscribeSignatureWithOptionsAsync( + string signature, + SignatureSubscriptionOptions options, + CancellationToken cancellationToken = default) { - var sink = CreateSubscriptionSink>(); - object[] parameters = [signature, new CommitmentConfig { Commitment = commitment }]; - var subscription = await RegisterAsync("signatureSubscribe", parameters, "signatureUnsubscribe", sink, cancellationToken); + ArgumentNullException.ThrowIfNull(options); + return SubscribeSignatureCoreAsync( + signature, + options.Commitment, + options.EnableReceivedNotification, + cancellationToken); + } - cancellationToken.Register(() => Cancel(subscription, cancellationToken)); + private async Task>> SubscribeSignatureCoreAsync( + string signature, + Commitment? commitment, + bool? enableReceivedNotification, + CancellationToken cancellationToken) + { + var sink = CreateSubscriptionSink>(); + object[] parameters = + [ + signature, + new SignatureSubscribeConfig + { + Commitment = commitment, + EnableReceivedNotification = enableReceivedNotification + } + ]; + await RegisterAsync( + "signatureSubscribe", + parameters, + "signatureUnsubscribe", + sink, + cancellationToken, + OneShotBehavior.SignatureFinal, + allowReceivedNotification: enableReceivedNotification is true); return sink.Reader; } @@ -416,6 +820,7 @@ public async Task>> Subscri /// A token to cancel the wait. /// The signature's result once it reaches . /// is null. + /// is negative and not infinite. /// The signature was not confirmed in time. /// The was cancelled. public async Task ConfirmSignatureAsync( @@ -426,22 +831,51 @@ public async Task ConfirmSignatureAsync( { ArgumentNullException.ThrowIfNull(signature); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(timeout ?? TimeSpan.FromSeconds(60)); + var confirmationTimeout = timeout ?? TimeSpan.FromSeconds(60); + if (confirmationTimeout < TimeSpan.Zero && confirmationTimeout != Timeout.InfiniteTimeSpan) + throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "The confirmation timeout must be non-negative or infinite."); + + using var timeoutCts = new CancellationTokenSource(); + var timeoutTask = confirmationTimeout == Timeout.InfiniteTimeSpan + ? Task.CompletedTask + : CancelAfterAsync(timeoutCts, confirmationTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - var reader = await SubscribeSignatureAsync(signature, commitment, timeoutCts.Token); try { - var notification = await reader.ReadAsync(timeoutCts.Token); + var reader = await SubscribeSignatureAsync(signature, commitment, linkedCts.Token); + var notification = await reader.ReadAsync(linkedCts.Token); return notification.Value!; } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + catch (OperationCanceledException exception) + when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { - throw new TimeoutException($"Signature {signature} was not confirmed at {commitment} within the timeout."); + throw new TimeoutException( + $"Signature {signature} was not confirmed at {commitment} within the timeout.", exception); } finally { await timeoutCts.CancelAsync(); + await timeoutTask; + } + } + + private static async Task CancelAfterAsync(CancellationTokenSource source, TimeSpan timeout) + { + try + { + while (timeout > MaximumTimerDuration) + { + await Task.Delay(MaximumTimerDuration, source.Token); + timeout -= MaximumTimerDuration; + } + + await Task.Delay(timeout, source.Token); + await source.CancelAsync(); + } + catch (OperationCanceledException) when (source.IsCancellationRequested) + { + // Confirmation finished or caller cancellation won; the timeout task only needs to stop. } } @@ -464,11 +898,9 @@ private async IAsyncEnumerable SubscribeAsync( } finally { - if (_active.TryRemove(subscription.LocalId, out _) && subscription.ServerId != 0) - { - _byServerId.TryRemove(subscription.ServerId, out _); - await SendUnsubscribeAsync(unsubscribeMethod, subscription.ServerId); - } + var work = TryTerminate(subscription, exception: null, unsubscribe: true); + if (work is not null) + await ExecuteTerminalWorkAsync(work); } } @@ -477,24 +909,43 @@ private async Task RegisterAsync( object[] subscribeParams, string unsubscribeMethod, SubscriptionSink sink, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + OneShotBehavior oneShotBehavior = OneShotBehavior.None, + bool allowReceivedNotification = false) { - var localId = Interlocked.Increment(ref _nextLocalId); - var subscription = new Subscription(localId, subscribeMethod, subscribeParams, unsubscribeMethod, sink); - _active[localId] = subscription; + Subscription subscription; + ConnectionEpoch epoch; + lock (_stateGate) + { + ObjectDisposedException.ThrowIf(_phase is ClientPhase.Disposing or ClientPhase.Disposed, this); + if (_phase != ClientPhase.Connected || _connection is null) + throw new InvalidOperationException("The client is not connected."); + + var localId = ++_nextLocalId; + subscription = new Subscription( + localId, + subscribeMethod, + subscribeParams, + unsubscribeMethod, + sink, + oneShotBehavior, + allowReceivedNotification); + _active.Add(localId, subscription); + epoch = _connection; + } + + await AttachCancellationAsync(subscription, cancellationToken); try { - await EstablishAsync(subscription, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + await EstablishAsync(subscription, epoch, initial: true, cancellationToken); } - catch + catch (Exception exception) { - _active.TryRemove(localId, out _); - - // The ack may have fully landed just before this cancellation won the race for the pending - // task; release whatever it established (mirrors the recheck in Route for the opposite order). - if (subscription.ServerId != 0 && _byServerId.TryRemove(subscription.ServerId, out _)) - _ = SendUnsubscribeAsync(subscription.UnsubscribeMethod, subscription.ServerId); + var work = TryTerminate(subscription, exception, unsubscribe: true); + if (work is not null) + await ExecuteTerminalWorkAsync(work); throw; } @@ -504,118 +955,415 @@ private async Task RegisterAsync( // Sends the subscribe request and waits for the server to assign a subscription id. The receive // loop must be running concurrently to route the acknowledgement, so this is never awaited from it. - private async Task EstablishAsync(Subscription subscription, CancellationToken cancellationToken) + private async Task EstablishAsync( + Subscription subscription, + ConnectionEpoch epoch, + bool initial, + CancellationToken cancellationToken) { - var requestId = Interlocked.Increment(ref _nextRequestId); - var acked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pending[requestId] = new PendingSubscribe(acked, subscription); + PendingSubscribe pending; + lock (_stateGate) + { + if (subscription.Phase == SubscriptionPhase.Terminal) + throw new OperationCanceledException(cancellationToken); + if (_phase != ClientPhase.Connected || !ReferenceEquals(_connection, epoch)) + throw ConnectionChangedBeforeSend(); + if (_pending.Count >= _options.MaxPendingSubscriptionRequests) + { + throw new InvalidOperationException( + $"The maximum of {_options.MaxPendingSubscriptionRequests} pending subscription requests has been reached."); + } + + var requestId = ++_nextRequestId; + pending = new PendingSubscribe(requestId, epoch, subscription, initial); + subscription.Attempt = pending; + _pending.Add(requestId, pending); + } try { - await SendAsync( - new RpcRequest { Id = requestId, Method = subscription.SubscribeMethod, Params = subscription.Params }, - cancellationToken); + var sendTask = SendAsync( + epoch, + new RpcRequest + { + Id = pending.RequestId, + Method = subscription.SubscribeMethod, + Params = subscription.Params + }, + cancellationToken, + pending: pending); + + try + { + // Once the physical send starts it is owned by the connection rather than this caller. + // Keep cancellation prompt for the subscriber while observing the send in the background. + await sendTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _ = SuppressAsync(sendTask); + throw; + } - await using (cancellationToken.Register(() => acked.TrySetCanceled(cancellationToken))) - await acked.Task; + await pending.Acked.Task.WaitAsync(_options.SubscriptionAckTimeout, cancellationToken); } - finally + catch (TimeoutException exception) + { + if (AbandonPendingOrObserveAcknowledged(pending)) + return; + + throw new TimeoutException( + $"The node did not acknowledge '{subscription.SubscribeMethod}' within {_options.SubscriptionAckTimeout}.", + exception); + } + catch { - _pending.TryRemove(requestId, out _); + if (AbandonPendingOrObserveAcknowledged(pending)) + return; + throw; } } - private void Cancel(Subscription subscription, CancellationToken cancellationToken) + private bool AbandonPendingOrObserveAcknowledged(PendingSubscribe pending) { - if (!_active.TryRemove(subscription.LocalId, out _)) - return; + lock (_stateGate) + { + // A successful completion is the commit point. A late ACK may already have been routed + // for cleanup, but it must not turn the caller's cancellation or timeout into success. + if (pending.Acked.Task.IsCompletedSuccessfully) + return true; - subscription.Sink.Complete(new OperationCanceledException(cancellationToken)); + if (pending.State == PendingState.Awaiting) + { + AbandonPendingLocked(pending); + pending.Acked.TrySetCanceled(); + } - if (subscription.ServerId != 0) - { - _byServerId.TryRemove(subscription.ServerId, out _); - _ = SendUnsubscribeAsync(subscription.UnsubscribeMethod, subscription.ServerId); + return false; } } - private async Task SendUnsubscribeAsync(string method, long subscriptionId) + private void AbandonPendingLocked(PendingSubscribe pending) { - try + var subscription = pending.Subscription; + if (pending.MayHaveBeenSent) { - var requestId = Interlocked.Increment(ref _nextRequestId); - await SendAsync(new RpcRequest { Id = requestId, Method = method, Params = [subscriptionId] }, CancellationToken.None); + // A possibly-sent request needs a generation-scoped tombstone so a late successful ACK + // can be unsubscribed. Requests cancelled before the physical send need no such entry. + pending.State = PendingState.Abandoned; } - catch (Exception ex) + else { - _logger.LogDebug(ex, "Solana WS unsubscribe '{Method}' (id {SubscriptionId}) failed", method, subscriptionId); + _pending.Remove(pending.RequestId); + pending.State = PendingState.Failed; } + + if (subscription is not null && ReferenceEquals(subscription.Attempt, pending)) + subscription.Attempt = null; + + // Retain only request metadata needed to clean up a late successful ACK. In particular, the + // tombstone must not retain the sink, parameters, cancellation source, or consumer state. + pending.DetachSubscription(); } - private async Task SendAsync(RpcRequest request, CancellationToken cancellationToken) + private async ValueTask AttachCancellationAsync( + Subscription subscription, + CancellationToken cancellationToken) { - var connection = _connection ?? throw new InvalidOperationException("The client is not connected."); - var json = JsonSerializer.Serialize(request, RpcJson.TypeInfo()); + if (!cancellationToken.CanBeCanceled) + return; + + var state = new CancellationState(this, subscription, cancellationToken); + var registration = cancellationToken.UnsafeRegister( + static callbackState => ((CancellationState)callbackState!).Cancel(), state); + + var keep = false; + lock (_stateGate) + { + if (subscription.Phase != SubscriptionPhase.Terminal) + { + subscription.CancellationRegistration = registration; + subscription.HasCancellationRegistration = true; + _cancellationRegistrationCount++; + keep = true; + } + } + + // Register invokes synchronously for an already-cancelled token. The callback may therefore + // terminalize the subscription before this registration can be attached. + if (!keep) + await registration.DisposeAsync(); + } + + private void Cancel(Subscription subscription, CancellationToken cancellationToken) + { + var work = TryTerminate( + subscription, new OperationCanceledException(cancellationToken), unsubscribe: true); + if (work is null) + return; + + work.Subscription.Sink.Complete(work.Exception); + work.CancellationRegistration?.Dispose(); + + if (work.Binding is not null) + _ = SendUnsubscribeReservedAsync( + work.Binding.Value, work.Subscription.UnsubscribeMethod, work.SendReservationHeld); + } + + private TerminalWork? TryTerminate( + Subscription subscription, + Exception? exception, + bool unsubscribe) + { + lock (_stateGate) + return TryTerminateLocked(subscription, exception, unsubscribe); + } + + private TerminalWork? TryTerminateLocked( + Subscription subscription, + Exception? exception, + bool unsubscribe) + { + if (subscription.Phase == SubscriptionPhase.Terminal) + return null; + + subscription.Phase = SubscriptionPhase.Terminal; + _active.Remove(subscription.LocalId); + + if (subscription.Attempt is { } attempt && attempt.State == PendingState.Awaiting) + { + AbandonPendingLocked(attempt); + if (exception is OperationCanceledException canceled) + attempt.Acked.TrySetCanceled(canceled.CancellationToken); + else + attempt.Acked.TrySetException( + exception ?? new InvalidOperationException("The subscription ended before acknowledgement.")); + } + + var binding = subscription.Binding; + if (binding is not null) + { + _byServerId.Remove((binding.Value.Epoch.Generation, binding.Value.ServerId)); + subscription.Binding = null; + } + + CancellationTokenRegistration? registration = null; + if (subscription.HasCancellationRegistration) + { + registration = subscription.CancellationRegistration; + subscription.HasCancellationRegistration = false; + _cancellationRegistrationCount--; + } + + var reservationHeld = unsubscribe && binding is not null && TryReserveSendLocked(binding.Value.Epoch); + return new TerminalWork(subscription, exception, binding, registration, reservationHeld); + } + + private async Task ExecuteTerminalWorkAsync(TerminalWork work) + { + work.Subscription.Sink.Complete(work.Exception); + if (work.CancellationRegistration is { } registration) + await registration.DisposeAsync(); + + if (work.Binding is not null) + await SendUnsubscribeReservedAsync( + work.Binding.Value, work.Subscription.UnsubscribeMethod, work.SendReservationHeld); + } + + private async Task SendUnsubscribeReservedAsync( + RouteBinding binding, + string method, + bool reservationHeld) + { + if (!reservationHeld) + return; - await _sendLock.WaitAsync(cancellationToken); try { - await connection.SendAsync(json, cancellationToken); + int requestId; + lock (_stateGate) + requestId = ++_nextRequestId; + + await SendAsync( + binding.Epoch, + new RpcRequest { Id = requestId, Method = method, Params = [binding.ServerId] }, + _lifetimeCts.Token, + reservationHeld: true); + } + catch (Exception exception) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + exception, + "Solana WS unsubscribe '{Method}' (id {SubscriptionId}) failed", + method, + binding.ServerId); + } + } + } + + private async Task SendAsync( + ConnectionEpoch epoch, + RpcRequest request, + CancellationToken cancellationToken, + bool reservationHeld = false, + PendingSubscribe? pending = null) + { + if (!reservationHeld) + { + lock (_stateGate) + { + if (!TryReserveSendLocked(epoch)) + throw ConnectionChangedBeforeSend(); + } + } + + try + { + var json = JsonSerializer.Serialize(request, RpcJson.TypeInfo()); + using var waitCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, _lifetimeCts.Token, epoch.Token); + await _sendLock.WaitAsync(waitCancellation.Token); + try + { + lock (_stateGate) + { + if (_phase != ClientPhase.Connected || !ReferenceEquals(_connection, epoch)) + throw ConnectionChangedBeforeSend(); + + if (pending is not null) + { + cancellationToken.ThrowIfCancellationRequested(); + if (pending.State != PendingState.Awaiting || + !_pending.TryGetValue(pending.RequestId, out var current) || + !ReferenceEquals(current, pending)) + { + throw new InvalidOperationException( + "The subscription ended before its request was sent."); + } + + // Cancellation before this point is definitely pre-send and removes the + // pending entry. From here on, retain a tombstone on cancellation because + // the request may reach the server even if the transport later reports failure. + pending.MayHaveBeenSent = true; + } + } + + using var transportCancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCts.Token, epoch.Token); + await epoch.Connection.SendAsync(json, transportCancellation.Token); + } + finally + { + _sendLock.Release(); + } } finally { - _sendLock.Release(); + ReleaseSendReservation(); + } + } + + private bool TryReserveSendLocked(ConnectionEpoch epoch) + { + if (_phase != ClientPhase.Connected || !ReferenceEquals(_connection, epoch)) + return false; + + if (_sendOperationCount++ == 0) + _sendOperationsDrained = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return true; + } + + private void ReleaseSendReservation() + { + lock (_stateGate) + { + if (--_sendOperationCount == 0) + _sendOperationsDrained!.TrySetResult(); } } - private async Task RunAsync(CancellationToken token) + private async Task RunAsync(ConnectionEpoch epoch, CancellationToken token) { + var replayTask = Task.CompletedTask; while (true) { Exception? failure; try { - failure = await ReceiveUntilClosedAsync(_connection!, token); + failure = await ReceiveUntilClosedAsync(epoch, epoch.Token); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (token.IsCancellationRequested || epoch.Token.IsCancellationRequested) { + await SuppressAsync(replayTask); return; } if (token.IsCancellationRequested) + { + await SuppressAsync(replayTask); return; + } var reason = failure ?? new InvalidOperationException("The WebSocket connection was closed."); _logger.LogWarning(reason, "Solana WS connection dropped: {Reason}", reason.Message); - FaultPending(reason); + EndGeneration(epoch, reason); + await epoch.CancelAsync(); + await SuppressAsync(replayTask); + await epoch.DisposeOnceAsync(); - if (!_options.AutoReconnect || !await TryReconnectAsync(token)) + var reconnected = _options.AutoReconnect + ? await TryReconnectAsync(token) + : null; + if (reconnected is null) { - _logger.LogError(reason, "Solana WS disconnected and not reconnected; completing {Count} subscription(s)", _active.Count); - CompleteAll(reason); + lock (_stateGate) + { + if (token.IsCancellationRequested || + _phase is ClientPhase.Disposing or ClientPhase.Disposed) + { + return; + } + } + + int count; + lock (_stateGate) + count = _active.Count; + _logger.LogError( + reason, + "Solana WS disconnected and not reconnected; completing {Count} subscription(s)", + count); + await CompleteAllAsync(reason); return; } - _logger.LogDebug("Solana WS reconnected; replaying {Count} subscription(s)", _active.Count); + epoch = reconnected; + int activeCount; + lock (_stateGate) + activeCount = _active.Count; + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Solana WS reconnected; replaying {Count} subscription(s)", activeCount); - // Re-enter the receive loop below so it can route the acks; resubscribe off-thread to avoid a deadlock. - _ = ResubscribeAllAsync(Volatile.Read(ref _connectionGeneration), token); + // Receive and replay must run concurrently so acknowledgements can be routed. The replay task + // remains owned by this generation and is joined before another generation can be published. + replayTask = ResubscribeAllAsync(epoch, epoch.Token); } } - private async Task ReceiveUntilClosedAsync(IWebSocketConnection connection, CancellationToken token) + private async Task ReceiveUntilClosedAsync(ConnectionEpoch epoch, CancellationToken token) { try { while (true) { - var message = await ReceiveWithTimeoutAsync(connection, token); + var message = await ReceiveWithTimeoutAsync(epoch.Connection, token); if (message is null) return null; - Route(message); + await RouteAsync(message, epoch); } } catch (OperationCanceledException) when (token.IsCancellationRequested) @@ -648,201 +1396,477 @@ private async Task RunAsync(CancellationToken token) } } - private async Task TryReconnectAsync(CancellationToken token) + private async Task TryReconnectAsync(CancellationToken token) { - if (_connection is not null) - await SafeDisposeAsync(_connection); - var delay = _options.ReconnectInitialDelay; for (var attempt = 0; _options.MaxReconnectAttempts == 0 || attempt < _options.MaxReconnectAttempts; attempt++) { + ConnectionEpoch? candidate = null; try { await Task.Delay(delay, token); - var connection = _connectionFactory(); - await connection.ConnectAsync(_endpoint!, token); - _connection = connection; - Interlocked.Increment(ref _connectionGeneration); - return true; + candidate = CreateConnectionEpoch(); + lock (_stateGate) + { + ObjectDisposedException.ThrowIf(_phase != ClientPhase.Reconnecting, this); + _connecting = candidate; + } + + using var linked = CancellationTokenSource.CreateLinkedTokenSource(token, candidate.Token); + await candidate.Connection.ConnectAsync(_endpoint!, linked.Token); + + lock (_stateGate) + { + ObjectDisposedException.ThrowIf( + _phase != ClientPhase.Reconnecting || !ReferenceEquals(_connecting, candidate), + this); + + _connecting = null; + _connection = candidate; + _phase = ClientPhase.Connected; + } + + return candidate; } catch (OperationCanceledException) + when (token.IsCancellationRequested || candidate?.Token.IsCancellationRequested is true) { - return false; + ClearConnecting(candidate); + if (candidate is not null) + await candidate.DisposeOnceAsync(); + return null; } - catch (Exception ex) + catch (Exception exception) { - _logger.LogDebug(ex, "Solana WS reconnect attempt {Attempt} failed; retrying in {Delay}", attempt + 1, delay); + ClearConnecting(candidate); + if (candidate is not null) + await candidate.DisposeOnceAsync(); + if (token.IsCancellationRequested) + return null; + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + exception, + "Solana WS reconnect attempt {Attempt} failed; retrying in {Delay}", + attempt + 1, + delay); + } + delay = NextDelay(delay); } } - return false; + return null; } + private void ClearConnecting(ConnectionEpoch? candidate) + { + lock (_stateGate) + { + if (ReferenceEquals(_connecting, candidate)) + _connecting = null; + } + } + + private ConnectionEpoch CreateConnectionEpoch() + => new(Interlocked.Increment(ref _connectionGeneration), _connectionFactory(), SafeDisposeAsync); + private TimeSpan NextDelay(TimeSpan current) { - var doubled = current + current; - return doubled < _options.ReconnectMaxDelay ? doubled : _options.ReconnectMaxDelay; + if (current == TimeSpan.Zero) + { + return TimeSpan.FromTicks(Math.Min( + TimeSpan.FromMilliseconds(1).Ticks, + _options.ReconnectMaxDelay.Ticks)); + } + + if (current.Ticks >= _options.ReconnectMaxDelay.Ticks / 2) + return _options.ReconnectMaxDelay; + + return TimeSpan.FromTicks(current.Ticks * 2); } - // Replays the established subscriptions onto the freshly reconnected socket, giving each a new server id. - // A stale replay (a newer reconnect has bumped the generation) bails so it cannot double-subscribe; a - // failed replay is left in place so the next reconnect retries it rather than dropping the consumer. - private async Task ResubscribeAllAsync(long generation, CancellationToken token) + private void EndGeneration(ConnectionEpoch epoch, Exception exception) { - var established = _active.Values.Where(subscription => subscription.Established).ToList(); - _byServerId.Clear(); + lock (_stateGate) + { + if (ReferenceEquals(_connection, epoch)) + _connection = null; + if (_phase == ClientPhase.Connected) + _phase = ClientPhase.Reconnecting; - foreach (var subscription in established) - subscription.ServerId = 0; + foreach (var key in _byServerId.Keys + .Where(key => key.Generation == epoch.Generation) + .ToArray()) + _byServerId.Remove(key); + + foreach (var subscription in _active.Values) + { + if (subscription.Binding is { } binding && ReferenceEquals(binding.Epoch, epoch)) + subscription.Binding = null; + } + + foreach (var pair in _pending + .Where(pair => ReferenceEquals(pair.Value.Epoch, epoch)) + .ToArray()) + { + _pending.Remove(pair.Key); + var pending = pair.Value; + if (pending.State != PendingState.Awaiting) + continue; + + pending.State = PendingState.Failed; + var subscription = pending.Subscription; + if (subscription is not null && ReferenceEquals(subscription.Attempt, pending)) + subscription.Attempt = null; + pending.Acked.TrySetException(new ConnectionEpochEndedException(exception)); + } + } + } + + // Every replay operation is bound to this exact physical connection. A stale replay can therefore + // neither send on nor mutate routing for a newer generation. + private async Task ResubscribeAllAsync(ConnectionEpoch epoch, CancellationToken token) + { + List established; + lock (_stateGate) + { + established = + [ + .. _active.Values.Where(subscription => + subscription.Phase == SubscriptionPhase.Active && subscription.Binding is null) + ]; + } foreach (var subscription in established) { - if (token.IsCancellationRequested || Volatile.Read(ref _connectionGeneration) != generation) + if (token.IsCancellationRequested) return; - // The consumer may have gone away while the connection was down; do not replay for nobody. - if (!_active.ContainsKey(subscription.LocalId)) - continue; + lock (_stateGate) + { + if (_phase != ClientPhase.Connected || !ReferenceEquals(_connection, epoch)) + return; + if (subscription.Phase != SubscriptionPhase.Active || subscription.Binding is not null) + continue; + } try { - await EstablishAsync(subscription, token); + await EstablishAsync(subscription, epoch, initial: false, token); } - catch (OperationCanceledException) - { - return; - } - catch (Exception ex) + catch (Exception exception) { - _logger.LogWarning(ex, "Solana WS failed to replay subscription '{Method}'", subscription.SubscribeMethod); + TerminalWork? work; + bool generationEnded; + lock (_stateGate) + { + generationEnded = exception is ConnectionEpochEndedException || + (exception is OperationCanceledException && + (token.IsCancellationRequested || _lifetimeCts.IsCancellationRequested)); + work = generationEnded + ? null + : TryTerminateLocked(subscription, exception, unsubscribe: true); + } + + // Only the connection epoch ending stops the replay loop. Cancellation or failure of + // one subscription terminalizes (or has already terminalized) that subscription and + // replay proceeds with the remaining snapshot entries. + if (generationEnded) + return; + + if (work is null) + continue; + + _logger.LogWarning( + exception, + "Solana WS failed to replay subscription '{Method}'; faulting that subscription", + subscription.SubscribeMethod); + await ExecuteTerminalWorkAsync(work); } } } - private void Route(string message) + private async Task RouteAsync(string message, ConnectionEpoch epoch) { using var document = JsonDocument.Parse(message); var root = document.RootElement; - if (root.TryGetProperty("id", out var idElement) && idElement.TryGetInt32(out var requestId)) + if (!root.TryGetProperty("jsonrpc", out var jsonRpcElement) || + jsonRpcElement.ValueKind != JsonValueKind.String || + !string.Equals(jsonRpcElement.GetString(), "2.0", StringComparison.Ordinal)) + { + throw new InvalidDataException("The node sent a WebSocket message without JSON-RPC version 2.0."); + } + + if (root.TryGetProperty("id", out var idElement)) { - if (root.TryGetProperty("result", out var resultElement) && - _pending.TryRemove(requestId, out var pending)) + if (idElement.ValueKind != JsonValueKind.Number || !idElement.TryGetInt32(out var requestId)) + throw new InvalidDataException("A WebSocket JSON-RPC response carried a non-integer request id."); + + var hasResult = root.TryGetProperty("result", out var resultElement); + var hasError = root.TryGetProperty("error", out var errorElement) && + errorElement.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined); + if (hasResult == hasError) { - if (resultElement.ValueKind == JsonValueKind.Number && resultElement.TryGetInt64(out var subscriptionId)) - { - pending.Subscription.ServerId = subscriptionId; - pending.Subscription.Established = true; - _byServerId[subscriptionId] = pending.Subscription; - pending.Acked.TrySetResult(subscriptionId); - - // The consumer may have gone away while this (re)subscribe was in flight - during a - // replay its cancellation saw ServerId still 0 and could not unsubscribe, so releasing - // the server-side subscription falls to the ack; otherwise it would be resurrected - // with nobody consuming it and nothing ever unsubscribing it. - if (!_active.ContainsKey(pending.Subscription.LocalId) && - _byServerId.TryRemove(subscriptionId, out _)) - { - _ = SendUnsubscribeAsync(pending.Subscription.UnsubscribeMethod, subscriptionId); - } - } - else + throw new InvalidDataException( + "A WebSocket JSON-RPC response must carry exactly one of result or a non-null error."); + } + + if (hasError) + { + if (errorElement.ValueKind != JsonValueKind.Object || + !errorElement.TryGetProperty("code", out var codeElement) || + codeElement.ValueKind != JsonValueKind.Number || + !codeElement.TryGetInt64(out _) || + !errorElement.TryGetProperty("message", out var messageElement) || + messageElement.ValueKind != JsonValueKind.String) { - pending.Acked.TrySetException(new InvalidOperationException("The node rejected the subscription.")); + throw new InvalidDataException("A WebSocket JSON-RPC response carried a malformed error object."); } + CompletePendingError(requestId, epoch, errorElement); return; } - // JSON-RPC error response: {"jsonrpc":"2.0","error":{"code":...,"message":"..."},"id":N}. - // Without this branch a rejected subscribe never resolves its ack and the caller hangs forever. - if (root.TryGetProperty("error", out var errorElement) && - _pending.TryRemove(requestId, out var faulted)) - { - // The error member is an object per JSON-RPC, but guard the shape anyway: TryGetProperty - // throws on a non-object element, and one malformed frame must not read as a dropped connection. - var detail = errorElement.ValueKind == JsonValueKind.Object && - errorElement.TryGetProperty("message", out var errorMessage) && - errorMessage.ValueKind == JsonValueKind.String - ? errorMessage.GetString() - : errorElement.GetRawText(); - var code = errorElement.ValueKind == JsonValueKind.Object && - errorElement.TryGetProperty("code", out var codeElement) && - codeElement.TryGetInt64(out var codeValue) - ? codeValue - : 0; + await CompletePendingResultAsync(requestId, epoch, resultElement); + return; + } - _logger.LogWarning( - "Solana WS request {RequestId} ('{Method}') rejected by the node (code {Code}): {Detail}", - requestId, faulted.Subscription.SubscribeMethod, code, detail); + if (!root.TryGetProperty("params", out var paramsElement) || + !paramsElement.TryGetProperty("subscription", out var subscriptionElement) || + !paramsElement.TryGetProperty("result", out var notification) || + !subscriptionElement.TryGetUInt64(out var notified)) + { + return; + } - faulted.Acked.TrySetException( - new InvalidOperationException($"The node rejected '{faulted.Subscription.SubscribeMethod}' (code {code}): {detail}")); + Subscription? subscription; + TerminalWork? oneShotWork = null; + lock (_stateGate) + { + var key = (epoch.Generation, ServerId: notified); + if (!_byServerId.TryGetValue(key, out subscription) || + subscription.Binding is not { } binding || + !ReferenceEquals(binding.Epoch, epoch) || + binding.ServerId != notified) + { return; } - // Unsubscribe acks and replies to requests we no longer track land here; nothing to route. - return; + if (!root.TryGetProperty("method", out var methodElement) || + methodElement.ValueKind != JsonValueKind.String || + !string.Equals( + methodElement.GetString(), + subscription.NotificationMethod, + StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"The node routed subscription id {notified} as an unexpected notification method; " + + $"expected '{subscription.NotificationMethod}'."); + } + + if (subscription.ShouldTerminateAfter(notification)) + oneShotWork = TryTerminateLocked(subscription, exception: null, unsubscribe: false); } - if (root.TryGetProperty("params", out var paramsElement) && - paramsElement.TryGetProperty("subscription", out var subscriptionElement) && - paramsElement.TryGetProperty("result", out var notification) && - subscriptionElement.TryGetInt64(out var notified) && - _byServerId.TryGetValue(notified, out var subscription)) + if (oneShotWork is not null) { try { + subscription.ValidateNotification(notification); subscription.Sink.Deliver(notification); + subscription.Sink.Complete(exception: null); } catch (Exception exception) { - FaultSubscription(subscription, exception); + subscription.Sink.Complete(exception); } + + if (oneShotWork.CancellationRegistration is { } registration) + await registration.DisposeAsync(); + return; + } + + try + { + subscription.ValidateNotification(notification); + subscription.Sink.Deliver(notification); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Solana WS could not decode a '{Method}' notification; faulting that subscription", + subscription.SubscribeMethod); + var work = TryTerminate(subscription, exception, unsubscribe: true); + if (work is not null) + await ExecuteTerminalWorkAsync(work); } } - // A notification that cannot be decoded faults only its own subscription: the consumer sees the decode - // error on its channel or stream instead of a silent stall, and the connection and every other - // subscription keep going. Letting the exception escape here would read as a dropped connection and, - // with auto-reconnect and a systematically undecodable payload, loop drop/replay forever. - private void FaultSubscription(Subscription subscription, Exception exception) + private async Task CompletePendingResultAsync( + int requestId, + ConnectionEpoch epoch, + JsonElement result) { - _logger.LogWarning( - exception, "Solana WS could not decode a '{Method}' notification; faulting that subscription", subscription.SubscribeMethod); + RouteBinding? lateBinding = null; + string? lateUnsubscribeMethod = null; + var reservationHeld = false; - // Drop the routing entry even when the subscription is already gone from _active (a cancel racing - // this fault): a late Cancel may have skipped it, and a stale entry would route notifications to a - // completed sink forever. - _byServerId.TryRemove(subscription.ServerId, out _); + lock (_stateGate) + { + if (!_pending.TryGetValue(requestId, out var pending) || + !ReferenceEquals(pending.Epoch, epoch)) + { + return; + } - if (!_active.TryRemove(subscription.LocalId, out _)) - return; + var subscription = pending.Subscription; + var wasAwaiting = pending.State == PendingState.Awaiting && subscription is not null; + if (result.ValueKind != JsonValueKind.Number || !result.TryGetUInt64(out var subscriptionId)) + { + _pending.Remove(requestId); + pending.State = PendingState.Failed; + if (subscription is not null && ReferenceEquals(subscription.Attempt, pending)) + subscription.Attempt = null; + if (wasAwaiting) + pending.Acked.TrySetException(new InvalidOperationException("The node rejected the subscription.")); + return; + } + + if (_byServerId.ContainsKey((epoch.Generation, subscriptionId))) + { + // The id is the sole routing key for notifications and unsubscriptions. Accepting + // an ambiguous id could misroute data or unsubscribe the existing subscription. + // Leave this pending entry intact so EndGeneration can fault its waiter. + throw new InvalidDataException( + $"The node assigned duplicate WebSocket subscription id {subscriptionId}."); + } + + var canAccept = wasAwaiting && + subscription!.Phase != SubscriptionPhase.Terminal && + ReferenceEquals(subscription.Attempt, pending) && + _phase == ClientPhase.Connected && + ReferenceEquals(_connection, epoch); + _pending.Remove(requestId); - subscription.Sink.Complete(exception); - _ = SendUnsubscribeAsync(subscription.UnsubscribeMethod, subscription.ServerId); + if (canAccept) + { + pending.State = PendingState.Acknowledged; + var binding = new RouteBinding(epoch, subscriptionId); + subscription!.Attempt = null; + subscription.Binding = binding; + if (pending.Initial) + subscription.Phase = SubscriptionPhase.Active; + _byServerId[(epoch.Generation, subscriptionId)] = subscription; + pending.Acked.TrySetResult(subscriptionId); + } + else + { + pending.State = PendingState.LateAcknowledged; + lateBinding = new RouteBinding(epoch, subscriptionId); + lateUnsubscribeMethod = pending.UnsubscribeMethod; + reservationHeld = TryReserveSendLocked(epoch); + } + } + + if (lateBinding is not null) + await SendUnsubscribeReservedAsync( + lateBinding.Value, lateUnsubscribeMethod!, reservationHeld); } - private void FaultPending(Exception exception) + private void CompletePendingError(int requestId, ConnectionEpoch epoch, JsonElement errorElement) { - foreach (var pending in _pending.Values) - pending.Acked.TrySetException(exception); - _pending.Clear(); + var detail = errorElement.ValueKind == JsonValueKind.Object && + errorElement.TryGetProperty("message", out var errorMessage) && + errorMessage.ValueKind == JsonValueKind.String + ? errorMessage.GetString() + : errorElement.GetRawText(); + var code = errorElement.ValueKind == JsonValueKind.Object && + errorElement.TryGetProperty("code", out var codeElement) && + codeElement.TryGetInt64(out var codeValue) + ? codeValue + : 0; + + string method; + lock (_stateGate) + { + if (!_pending.TryGetValue(requestId, out var pending) || + !ReferenceEquals(pending.Epoch, epoch)) + { + return; + } + + _pending.Remove(requestId); + method = pending.SubscribeMethod; + var subscription = pending.Subscription; + var wasAwaiting = pending.State == PendingState.Awaiting && subscription is not null; + pending.State = PendingState.Failed; + if (subscription is not null && ReferenceEquals(subscription.Attempt, pending)) + subscription.Attempt = null; + if (wasAwaiting) + { + pending.Acked.TrySetException( + new InvalidOperationException( + $"The node rejected '{method}' (code {code}): {detail}")); + } + } + + _logger.LogWarning( + "Solana WS request {RequestId} ('{Method}') rejected by the node (code {Code}): {Detail}", + requestId, + method, + code, + detail); } - // A null exception is an orderly shutdown: each subscription's channel or stream completes without an - // error, so consumers observe the end of the stream. A non-null exception (a connection that dropped and - // will not be re-established) faults them instead. In-flight subscribes always fault - they can never - // be acknowledged. - private void CompleteAll(Exception? exception) + private async Task CompleteAllAsync(Exception? exception) { - FaultPending(exception ?? new ObjectDisposedException(nameof(SolanaWsClient))); + List work; + lock (_stateGate) + { + if (_phase is not (ClientPhase.Disposing or ClientPhase.Disposed)) + _phase = ClientPhase.Stopped; + + var pendingException = exception ?? new ObjectDisposedException(nameof(SolanaWsClient)); + work = + [ + .. _active.Values + .ToArray() + .Select(subscription => TryTerminateLocked( + subscription, + subscription.Phase == SubscriptionPhase.Establishing ? pendingException : exception, + unsubscribe: false)) + .OfType() + ]; + + foreach (var pending in _pending.Values) + { + if (pending.State == PendingState.Awaiting) + pending.Acked.TrySetException(pendingException); + pending.State = PendingState.Failed; + } - foreach (var subscription in _active.Values) - subscription.Sink.Complete(exception); - _active.Clear(); - _byServerId.Clear(); + _pending.Clear(); + _byServerId.Clear(); + } + + foreach (var item in work) + await ExecuteTerminalWorkAsync(item); + } + + private static async Task SuppressAsync(Task task) + { + try + { + await task; + } + catch + { + // The owning operation has already translated or logged the failure. + } } private async Task SafeDisposeAsync(IWebSocketConnection connection) @@ -863,43 +1887,174 @@ private async Task SafeDisposeAsync(IWebSocketConnection connection) /// . Safe to call more than once. /// /// A task that completes once cleanup is done. - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - if (_disposed) - return; - _disposed = true; + TaskCompletionSource completion; + ConnectionEpoch? connection; + ConnectionEpoch? connecting; + Task? runLoop; + Task? connectTask; + Task sendOperationsDrained; + + lock (_stateGate) + { + if (_disposeTask is not null) + return new ValueTask(_disposeTask); + + completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _disposeTask = completion.Task; + _phase = ClientPhase.Disposing; + runLoop = _runLoop; + connectTask = _connectTask; + connection = _connection; + connecting = _connecting; + _connection = null; + _connecting = null; + sendOperationsDrained = _sendOperationCount == 0 + ? Task.CompletedTask + : _sendOperationsDrained!.Task; + } - await _lifetimeCts.CancelAsync(); + _ = DisposeCoreAsync( + completion, connection, connecting, connectTask, runLoop, sendOperationsDrained); + return new ValueTask(completion.Task); + } - if (_runLoop is not null) + private async Task DisposeCoreAsync( + TaskCompletionSource completion, + ConnectionEpoch? connection, + ConnectionEpoch? connecting, + Task? connectTask, + Task? runLoop, + Task sendOperationsDrained) + { + try + { + await _lifetimeCts.CancelAsync(); + + var connectionDispose = connection?.DisposeOnceAsync() ?? Task.CompletedTask; + var connectingDispose = connecting?.DisposeOnceAsync() ?? Task.CompletedTask; + + await CompleteAllAsync(exception: null); + await Task.WhenAll(connectionDispose, connectingDispose); + + if (connectTask is not null) + await SuppressAsync(connectTask); + if (runLoop is not null) + await SuppressAsync(runLoop); + await sendOperationsDrained; + + _sendLock.Dispose(); + _lifetimeCts.Dispose(); + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Solana WS cleanup ended with an error during dispose"); + } + finally + { + lock (_stateGate) + _phase = ClientPhase.Disposed; + completion.TrySetResult(); + } + } + + private sealed class PendingSubscribe( + int requestId, + ConnectionEpoch epoch, + Subscription subscription, + bool initial) + { + public int RequestId { get; } = requestId; + + public ConnectionEpoch Epoch { get; } = epoch; + + public Subscription? Subscription { get; private set; } = subscription; + + public string SubscribeMethod { get; } = subscription.SubscribeMethod; + + public string UnsubscribeMethod { get; } = subscription.UnsubscribeMethod; + + public bool Initial { get; } = initial; + + public TaskCompletionSource Acked { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public PendingState State { get; set; } + + public bool MayHaveBeenSent { get; set; } + + public void DetachSubscription() => Subscription = null; + } + + private sealed class ConnectionEpoch + { + private readonly CancellationTokenSource _closed = new(); + private readonly Func _disposeConnection; + private readonly Lazy _dispose; + + public ConnectionEpoch( + long generation, + IWebSocketConnection connection, + Func disposeConnection) + { + Generation = generation; + Connection = connection; + _disposeConnection = disposeConnection; + _dispose = new Lazy(DisposeCoreAsync, LazyThreadSafetyMode.ExecutionAndPublication); + } + + public long Generation { get; } + + public IWebSocketConnection Connection { get; } + + public CancellationToken Token => _closed.Token; + + public Task CancelAsync() => _closed.CancelAsync(); + + public Task DisposeOnceAsync() => _dispose.Value; + + private async Task DisposeCoreAsync() { try { - await _runLoop; + // Keep the epoch receive token alive while the connection performs its close + // handshake. Client shutdown cancels the lifetime token separately, so sends and + // connects still stop promptly while the active receive can consume the peer close. + await _disposeConnection(Connection); } - catch (Exception exception) + finally { - _logger.LogDebug(exception, "Solana WS receive loop ended with an error during dispose"); + await _closed.CancelAsync(); } } + } - if (_connection is not null) - await SafeDisposeAsync(_connection); + private readonly record struct RouteBinding(ConnectionEpoch Epoch, ulong ServerId); - CompleteAll(exception: null); + private sealed record TerminalWork( + Subscription Subscription, + Exception? Exception, + RouteBinding? Binding, + CancellationTokenRegistration? CancellationRegistration, + bool SendReservationHeld); - _lifetimeCts.Dispose(); - _sendLock.Dispose(); + private sealed record CancellationState( + SolanaWsClient Client, + Subscription Subscription, + CancellationToken CancellationToken) + { + public void Cancel() => Client.Cancel(Subscription, CancellationToken); } - private readonly record struct PendingSubscribe(TaskCompletionSource Acked, Subscription Subscription); - private sealed class Subscription( long localId, string subscribeMethod, object[] parameters, string unsubscribeMethod, - ISubscriptionSink sink) + ISubscriptionSink sink, + OneShotBehavior oneShotBehavior, + bool allowReceivedNotification) { public long LocalId { get; } = localId; @@ -909,13 +2064,101 @@ private sealed class Subscription( public string UnsubscribeMethod { get; } = unsubscribeMethod; + public string NotificationMethod { get; } = subscribeMethod.EndsWith("Subscribe", StringComparison.Ordinal) + ? subscribeMethod[..^"Subscribe".Length] + "Notification" + : throw new ArgumentException("A subscription method must end with 'Subscribe'.", nameof(subscribeMethod)); + public ISubscriptionSink Sink { get; } = sink; - public long ServerId { get; set; } + public OneShotBehavior OneShotBehavior { get; } = oneShotBehavior; + + public bool AllowReceivedNotification { get; } = allowReceivedNotification; + + public void ValidateNotification(JsonElement notification) + { + if (SubscribeMethod is "accountSubscribe" or "logsSubscribe" or "programSubscribe" or "blockSubscribe" or "signatureSubscribe") + { + if (notification.ValueKind != JsonValueKind.Object || + !notification.TryGetProperty("value", out var value) || + value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + throw new JsonException($"A '{NotificationMethod}' payload must carry a non-null value."); + } + + if (SubscribeMethod == "signatureSubscribe" && + value.ValueKind == JsonValueKind.String && + !AllowReceivedNotification) + { + throw new JsonException( + "The node sent receivedSignature although the subscription did not request early notifications."); + } + } + } + + public bool ShouldTerminateAfter(JsonElement notification) => OneShotBehavior switch + { + OneShotBehavior.None => false, + OneShotBehavior.SignatureFinal => + notification.ValueKind == JsonValueKind.Object && + notification.TryGetProperty("value", out var value) && + value.ValueKind == JsonValueKind.Object, + _ => false + }; + + public SubscriptionPhase Phase { get; set; } = SubscriptionPhase.Establishing; + + public PendingSubscribe? Attempt { get; set; } + + public RouteBinding? Binding { get; set; } + + public CancellationTokenRegistration CancellationRegistration { get; set; } - public bool Established { get; set; } + public bool HasCancellationRegistration { get; set; } } + private enum ClientPhase + { + New, + Connecting, + Connected, + Reconnecting, + Stopped, + Disposing, + Disposed + } + + private enum SubscriptionPhase + { + Establishing, + Active, + Terminal + } + + private enum OneShotBehavior + { + None, + SignatureFinal + } + + private enum PendingState + { + Awaiting, + Abandoned, + Acknowledged, + LateAcknowledged, + Failed + } + + private static ConnectionEpochEndedException ConnectionChangedBeforeSend() + => new(new InvalidOperationException("The WebSocket connection changed before the request was sent.")); + + private sealed class ConnectionEpochEndedException(Exception innerException) + : InvalidOperationException(innerException.Message, innerException) + { + } + + private static readonly TimeSpan MaximumTimerDuration = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + private interface ISubscriptionSink { void Deliver(JsonElement result); @@ -939,7 +2182,7 @@ public SubscriptionSink(int capacity) _channel = Channel.CreateBounded(new BoundedChannelOptions(capacity) { SingleWriter = false, - SingleReader = true, + SingleReader = false, FullMode = BoundedChannelFullMode.Wait }); } @@ -948,8 +2191,10 @@ public SubscriptionSink(int capacity) public void Deliver(JsonElement result) { - var value = result.Deserialize(_typeInfo); - if (value is null || _channel.Writer.TryWrite(value)) + var value = result.Deserialize(_typeInfo) + ?? throw new JsonException("A WebSocket notification result decoded to null."); + + if (_channel.Writer.TryWrite(value)) return; // TryWrite also fails on a channel that was already completed - a notification racing the diff --git a/src/SolSharp.Rpc/Streaming/SolanaWsClientOptions.cs b/src/SolSharp.Rpc/Streaming/SolanaWsClientOptions.cs index 35db308..9ce029f 100644 --- a/src/SolSharp.Rpc/Streaming/SolanaWsClientOptions.cs +++ b/src/SolSharp.Rpc/Streaming/SolanaWsClientOptions.cs @@ -10,7 +10,10 @@ public sealed record SolanaWsClientOptions /// public bool AutoReconnect { get; init; } = true; - /// The delay before the first reconnect attempt; it doubles after each failed attempt, up to . + /// + /// The delay before the first reconnect attempt; it doubles after each failed attempt, up to + /// . When zero and the maximum is positive, subsequent delays start at 1 ms. + /// public TimeSpan ReconnectInitialDelay { get; init; } = TimeSpan.FromSeconds(1); /// The ceiling for the exponential reconnect backoff. @@ -19,6 +22,20 @@ public sealed record SolanaWsClientOptions /// The maximum number of reconnect attempts before giving up; 0 (the default) retries forever. public int MaxReconnectAttempts { get; init; } + /// + /// The maximum time to wait for a subscribe acknowledgement before failing that subscription. The + /// default is 30 seconds. This timeout is always finite so one missing acknowledgement cannot block + /// reconnect replay for every subscription behind it. + /// + public TimeSpan SubscriptionAckTimeout { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// The maximum number of subscription requests whose acknowledgements may be outstanding on one + /// connection. This includes compact records retained after a local timeout or cancellation so a + /// late successful acknowledgement can still be unsubscribed. The default is 1,024. + /// + public int MaxPendingSubscriptionRequests { get; init; } = 1024; + /// /// The maximum encoded size of one incoming WebSocket message, in bytes. The default is 64 MiB. /// Messages over the limit close the connection with MessageTooBig. diff --git a/src/SolSharp.Rpc/Streaming/SubscriptionOptions.cs b/src/SolSharp.Rpc/Streaming/SubscriptionOptions.cs new file mode 100644 index 0000000..14cfafc --- /dev/null +++ b/src/SolSharp.Rpc/Streaming/SubscriptionOptions.cs @@ -0,0 +1,120 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc.Streaming; + +/// +/// Effective upstream accountSubscribe configuration. Pinned Agave ignores the other fields present on +/// its shared HTTP account configuration, so they are intentionally not exposed here. +/// +public sealed record AccountSubscriptionOptions +{ + /// The account-data encoding; use the node's legacy binary default when null. + public RpcAccountEncoding? Encoding { get; init; } + + /// The commitment level at which account changes are delivered. + public Commitment? Commitment { get; init; } +} + +/// +/// Effective upstream programSubscribe configuration. Pinned Agave applies encoding, commitment, and +/// filters; its data-slice, context-shape, and sorting fields are accepted but ignored and are not exposed. +/// +public sealed record ProgramSubscriptionOptions +{ + /// The account-data encoding; use the node's legacy binary default when null. + public RpcAccountEncoding? Encoding { get; init; } + + /// The commitment level at which program-account changes are delivered. + public Commitment? Commitment { get; init; } + + /// Filters every delivered account must satisfy; apply none when null. + public IReadOnlyList? Filters { get; init; } +} + +/// Options for signatureSubscribe. +public sealed record SignatureSubscriptionOptions +{ + /// The commitment level at which the final notification is delivered. + public Commitment? Commitment { get; init; } + + /// + /// Requests an early "receivedSignature" notification before the final processed result. + /// + public bool EnableReceivedNotification { get; init; } +} + +/// The filter accepted by logsSubscribe. +public sealed class LogsSubscriptionFilter +{ + private LogsSubscriptionFilter(LogsSubscriptionFilterKind kind, PublicKey? mention) + { + Kind = kind; + Mention = mention; + } + + internal LogsSubscriptionFilterKind Kind { get; } + + internal PublicKey? Mention { get; } + + /// Includes all non-vote transactions. + public static LogsSubscriptionFilter All { get; } = new(LogsSubscriptionFilterKind.All, null); + + /// Includes all transactions, including simple vote transactions. + public static LogsSubscriptionFilter AllWithVotes { get; } = new(LogsSubscriptionFilterKind.AllWithVotes, null); + + /// Includes transactions that mention one account or program. + /// The single address to match. + /// A mentions filter. + public static LogsSubscriptionFilter Mentions(PublicKey accountOrProgram) => + new(LogsSubscriptionFilterKind.Mentions, accountOrProgram); +} + +internal enum LogsSubscriptionFilterKind +{ + /// All non-vote transactions. + All, + + /// All transactions, including votes. + AllWithVotes, + + /// Transactions mentioning one address. + Mentions +} + +/// The filter accepted by blockSubscribe. +public sealed class BlockSubscriptionFilter +{ + private BlockSubscriptionFilter(PublicKey? mention) => Mention = mention; + + internal PublicKey? Mention { get; } + + /// Includes every produced block. + public static BlockSubscriptionFilter All { get; } = new(null); + + /// Includes blocks that mention one account or program. + /// The address a block must mention. + /// A mentions filter. + public static BlockSubscriptionFilter Mentions(PublicKey accountOrProgram) => new(accountOrProgram); +} + +/// +/// Exact upstream blockSubscribe configuration. The block body is returned as JSON because its +/// schema depends on the encoding and transaction-detail choices. +/// +public sealed record BlockSubscriptionOptions +{ + /// The commitment level at which blocks are delivered. + public Commitment? Commitment { get; init; } + + /// The transaction encoding; use the node default when null. + public RpcTransactionEncoding? Encoding { get; init; } + + /// The transaction detail level; use the node default when null. + public RpcTransactionDetails? TransactionDetails { get; init; } + + /// Whether block-level rewards are included; use the node default when null. + public bool? ShowRewards { get; init; } + + /// The highest numeric transaction version the caller accepts. + public byte? MaxSupportedTransactionVersion { get; init; } +} diff --git a/src/SolSharp.Rpc/Streaming/VoteNotification.cs b/src/SolSharp.Rpc/Streaming/VoteNotification.cs index f51c37f..1b70959 100644 --- a/src/SolSharp.Rpc/Streaming/VoteNotification.cs +++ b/src/SolSharp.Rpc/Streaming/VoteNotification.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Serialization; using SolSharp.Core.Primitives; @@ -10,23 +11,44 @@ namespace SolSharp.Rpc.Streaming; /// voteSubscribe public sealed record VoteNotification { - /// The identity of the voting validator. + private IReadOnlyList? _slots; + private string? _hash; + private string? _signature; + + /// The vote-account address that produced the vote. [JsonPropertyName("votePubkey")] + [JsonRequired] public PublicKey VotePubkey { get; init; } /// The slots the vote covers. [JsonPropertyName("slots")] - public IReadOnlyList Slots { get; init; } = []; + [JsonRequired] + public IReadOnlyList Slots + { + get => _slots ?? throw new InvalidOperationException("The voted slots have not been initialized."); + init => _slots = value ?? throw new JsonException("A vote notification must carry its voted slots."); + } /// The hash the vote is for (base58). [JsonPropertyName("hash")] - public string Hash { get; init; } = string.Empty; + [JsonRequired] + public string Hash + { + get => _hash ?? throw new InvalidOperationException("The vote hash has not been initialized."); + init => _hash = value ?? throw new JsonException("A vote notification must carry a vote hash."); + } /// The vote's Unix timestamp in seconds, when the validator attached one. [JsonPropertyName("timestamp")] + [JsonRequired] public long? Timestamp { get; init; } /// The signature of the transaction carrying the vote (base58). [JsonPropertyName("signature")] - public string Signature { get; init; } = string.Empty; + [JsonRequired] + public string Signature + { + get => _signature ?? throw new InvalidOperationException("The vote signature has not been initialized."); + init => _signature = value ?? throw new JsonException("A vote notification must carry a signature."); + } } diff --git a/src/SolSharp.Rpc/TokenAccountsFilter.cs b/src/SolSharp.Rpc/TokenAccountsFilter.cs new file mode 100644 index 0000000..3d6cf54 --- /dev/null +++ b/src/SolSharp.Rpc/TokenAccountsFilter.cs @@ -0,0 +1,41 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc; + +/// +/// The mutually exclusive mint or token-program filter accepted by +/// getTokenAccountsByOwner and getTokenAccountsByDelegate. +/// +public sealed class TokenAccountsFilter +{ + private TokenAccountsFilter(TokenAccountsFilterKind kind, PublicKey address) + { + Kind = kind; + Address = address; + } + + internal TokenAccountsFilterKind Kind { get; } + + internal PublicKey Address { get; } + + /// Matches token accounts for one mint. + /// The mint to match. + /// A mint filter. + public static TokenAccountsFilter ByMint(PublicKey mint) => + new(TokenAccountsFilterKind.Mint, mint); + + /// Matches every account owned by one SPL Token program, such as Token or Token-2022. + /// The SPL Token program id to match. + /// A token-program filter. + public static TokenAccountsFilter ByProgramId(PublicKey programId) => + new(TokenAccountsFilterKind.ProgramId, programId); +} + +internal enum TokenAccountsFilterKind +{ + /// A mint-address filter. + Mint, + + /// An SPL Token program-id filter. + ProgramId +} diff --git a/src/SolSharp.Wallet/Bip39.cs b/src/SolSharp.Wallet/Bip39.cs index 3799bb9..58ac960 100644 --- a/src/SolSharp.Wallet/Bip39.cs +++ b/src/SolSharp.Wallet/Bip39.cs @@ -26,14 +26,16 @@ public static byte[] ToSeed(string mnemonic, string passphrase = "") ArgumentNullException.ThrowIfNull(passphrase); var password = Encoding.UTF8.GetBytes(mnemonic.Normalize(NormalizationForm.FormKD)); - var salt = Encoding.UTF8.GetBytes("mnemonic" + passphrase.Normalize(NormalizationForm.FormKD)); + byte[] salt = []; try { + salt = Encoding.UTF8.GetBytes("mnemonic" + passphrase.Normalize(NormalizationForm.FormKD)); return Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA512, SeedLength); } finally { CryptographicOperations.ZeroMemory(password); + CryptographicOperations.ZeroMemory(salt); } } } diff --git a/src/SolSharp.Wallet/BlsAggregatePublicKey.cs b/src/SolSharp.Wallet/BlsAggregatePublicKey.cs new file mode 100644 index 0000000..0218c7e --- /dev/null +++ b/src/SolSharp.Wallet/BlsAggregatePublicKey.cs @@ -0,0 +1,77 @@ +using System.Security.Cryptography; + +namespace SolSharp.Wallet; + +/// +/// An aggregate of one or more proof-of-possession-verified BLS public keys for same-message verification. +/// Raw public keys cannot be parsed directly into this type, preserving proof provenance at the API boundary. +/// +public sealed class BlsAggregatePublicKey : IEquatable +{ + private readonly byte[] _bytes; + + private BlsAggregatePublicKey(ReadOnlySpan bytes) + { + _bytes = bytes.ToArray(); + } + + /// + /// Aggregates one or more proof-of-possession-verified public keys with native BLS12-381 group addition. + /// Duplicate keys are included repeatedly, matching the pinned Solana SDK. + /// + /// The nonempty verified public keys to aggregate. + /// The aggregate public key. + /// is . + /// + /// The collection is empty, contains , or aggregates to the point at infinity. + /// + /// The native BLS backend rejects a validated input. + public static BlsAggregatePublicKey Aggregate(IReadOnlyList publicKeys) + { + ArgumentNullException.ThrowIfNull(publicKeys); + if (publicKeys.Count == 0) + throw new ArgumentException("At least one proof-verified BLS public key is required for aggregation.", nameof(publicKeys)); + if (publicKeys.Any(publicKey => publicKey is null)) + throw new ArgumentException("BLS public-key aggregation cannot contain null entries.", nameof(publicKeys)); + + var aggregate = BlsOperations.AggregatePublicKeys(publicKeys); + if (!BlsOperations.IsValidPublicKey(aggregate)) + throw new ArgumentException("BLS public keys must not aggregate to the point at infinity.", nameof(publicKeys)); + + return new BlsAggregatePublicKey(aggregate); + } + + /// Verifies a signature aggregate over one shared message with the pinned Solana signature DST. + /// The subgroup-checked signature aggregate. + /// The exact shared message signed by every participant. + /// only when the aggregate signature is valid for this aggregate key. + /// is . + public bool Verify(BlsSignature signature, ReadOnlySpan message) + { + ArgumentNullException.ThrowIfNull(signature); + return BlsOperations.VerifySignature(_bytes, signature.Bytes, message); + } + + /// Returns a new array containing the canonical compressed 48-byte aggregate public key. + /// A defensive copy of the compressed aggregate public key. + public byte[] ToBytes() => [.. _bytes]; + + /// + public bool Equals(BlsAggregatePublicKey? other) => + other is not null && _bytes.AsSpan().SequenceEqual(other._bytes); + + /// + public override bool Equals(object? obj) => obj is BlsAggregatePublicKey other && Equals(other); + + /// + public override int GetHashCode() + { + var hash = default(HashCode); + foreach (var value in _bytes) + hash.Add(value); + return hash.ToHashCode(); + } + + /// + public override string ToString() => Convert.ToBase64String(_bytes); +} diff --git a/src/SolSharp.Wallet/BlsKeypair.cs b/src/SolSharp.Wallet/BlsKeypair.cs new file mode 100644 index 0000000..5f78f50 --- /dev/null +++ b/src/SolSharp.Wallet/BlsKeypair.cs @@ -0,0 +1,427 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using SolSharp.Core.Primitives; + +namespace SolSharp.Wallet; + +/// +/// An in-memory minimal-public-key-size BLS12-381 keypair compatible with the pinned Solana SDK. +/// The 32-byte little-endian secret is zeroed on disposal or by the finalizer backstop. +/// +/// +/// The vetted native backend currently ships for linux-x64, linux-arm64, osx-x64, osx-arm64, and +/// win-x64. Other RIDs fail with or a native-loader error. +/// +public sealed class BlsKeypair : IDisposable +{ + /// The canonical little-endian secret-key length. + public const int SecretKeyLength = BlsOperations.SecretKeyLength; + + /// The uncompressed G1 public-key length used by the Rust keypair file format. + public const int UncompressedPublicKeyLength = 96; + + /// The exact keypair file length: 32-byte secret followed by the 96-byte uncompressed public key. + public const int KeypairLength = SecretKeyLength + UncompressedPublicKeyLength; + + /// The minimum input-key-material length accepted by the pinned SDK's key derivation. + public const int MinimumInputKeyMaterialLength = 32; + + /// The length of ALPENGLOW || vote_account. + public const int VoteProofPayloadLength = 41; + + private static ReadOnlySpan VoteProofDomain => "ALPENGLOW"u8; + + private readonly object _secretGate = new(); + private readonly byte[] _secretKey; + private bool _disposed; + + private BlsKeypair(byte[] secretKey) + { + _secretKey = secretKey; + PublicKey = BlsPublicKey.FromValidated(BlsOperations.DerivePublicKey(secretKey)); + PopVerifiedPublicKey = new BlsPopVerifiedPublicKey(PublicKey); + } + + /// The validated compressed BLS public key. + public BlsPublicKey PublicKey { get; } + + /// + /// The derived public key with trusted proof-of-possession provenance. Because this key was derived from + /// the locally held secret, it matches the pinned Rust keypair's PopVerified public-key boundary. + /// + public BlsPopVerifiedPublicKey PopVerifiedPublicKey { get; } + + /// Generates a keypair by applying the pinned BLS key-generation function to 32 random bytes. + /// A fresh BLS keypair. + public static BlsKeypair Generate() + { + Span inputKeyMaterial = stackalloc byte[MinimumInputKeyMaterialLength]; + RandomNumberGenerator.Fill(inputKeyMaterial); + try + { + return Derive(inputKeyMaterial); + } + finally + { + CryptographicOperations.ZeroMemory(inputKeyMaterial); + } + } + + /// Derives a BLS keypair with blst_keygen, matching the pinned Rust SDK. + /// At least 32 bytes of high-entropy input key material. + /// The deterministically derived keypair. + /// The input contains fewer than 32 bytes. + public static BlsKeypair Derive(ReadOnlySpan inputKeyMaterial) => + Create(BlsOperations.DeriveSecretKey(inputKeyMaterial)); + + /// + /// Derives a BLS keypair from an Ed25519 signer by signing + /// bls-key-derive- || public_seed and using the nonzero signature as input key material. + /// + /// The Solana signer providing the derivation signature. + /// Application-visible bytes that distinguish derived BLS keys. + /// The deterministic BLS keypair. + /// is . + /// The signer returns a malformed or all-zero placeholder signature. + public static BlsKeypair DeriveFromSigner(ISigner signer, ReadOnlySpan publicSeed) + { + ArgumentNullException.ThrowIfNull(signer); + + var message = GC.AllocateUninitializedArray(checked(15 + publicSeed.Length)); + "bls-key-derive-"u8.CopyTo(message); + publicSeed.CopyTo(message.AsSpan(15)); + + byte[]? signature = null; + try + { + signature = signer.Sign(message); + if (signature.Length != Signature.Length) + throw new CryptographicException($"The derivation signer returned {signature.Length} bytes instead of {Signature.Length}."); + + Span zero = stackalloc byte[Signature.Length]; + if (CryptographicOperations.FixedTimeEquals(signature, zero)) + throw new CryptographicException("An all-zero placeholder signature cannot be used as BLS input key material."); + + return Derive(signature); + } + finally + { + CryptographicOperations.ZeroMemory(message); + if (signature is not null) + CryptographicOperations.ZeroMemory(signature); + } + } + + /// Imports a canonical, nonzero 32-byte little-endian BLS secret scalar. + /// The canonical little-endian scalar. + /// The imported keypair. + /// The scalar has the wrong length, is noncanonical, or is zero. + public static BlsKeypair FromSecretKey(ReadOnlySpan secretKey) + { + if (!BlsOperations.IsCanonicalSecretKey(secretKey)) + throw new ArgumentException("BLS secret key must be a canonical nonzero 32-byte little-endian scalar.", nameof(secretKey)); + + return Create(secretKey.ToArray()); + } + + /// + /// Imports the pinned Rust keypair representation: a canonical 32-byte little-endian secret + /// followed by its exact 96-byte uncompressed G1 public key. + /// + /// The complete 128-byte keypair representation. + /// The validated keypair. + /// + /// The value has the wrong length, contains an invalid secret, or its public half does not match the secret. + /// + public static BlsKeypair FromBytes(ReadOnlySpan keypair) + { + if (keypair.Length != KeypairLength) + throw new ArgumentException($"BLS keypair must be exactly {KeypairLength} bytes.", nameof(keypair)); + + var secretKey = keypair[..SecretKeyLength]; + if (!BlsOperations.IsCanonicalSecretKey(secretKey)) + throw new ArgumentException("BLS keypair contains a noncanonical or zero secret scalar.", nameof(keypair)); + + var expectedPublicKey = BlsOperations.DeriveUncompressedPublicKey(secretKey); + try + { + if (!CryptographicOperations.FixedTimeEquals(expectedPublicKey, keypair[SecretKeyLength..])) + throw new ArgumentException("BLS keypair public key does not match its secret scalar.", nameof(keypair)); + } + finally + { + CryptographicOperations.ZeroMemory(expectedPublicKey); + } + + return Create(secretKey.ToArray()); + } + + /// + /// Imports a JSON byte array in the 128-byte pinned Rust keypair-file representation. + /// The immutable input string can contain secret material that cannot be cleared. + /// + /// A JSON array of exactly 128 integers in the range 0 through 255. + /// The validated keypair. + /// The input is null, empty, or whitespace. + /// The JSON or keypair representation is invalid. + public static BlsKeypair FromJsonArray(string json) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + try + { + return FromJsonValues(JsonSerializer.Deserialize(json, WalletJsonContext.Default.Int32Array)); + } + catch (JsonException exception) + { + throw new FormatException("BLS keypair is not a valid JSON number array.", exception); + } + } + + /// + /// Imports a UTF-8 JSON byte array in the 128-byte pinned Rust keypair-file representation. + /// The caller retains ownership of the input buffer and should clear it after use. + /// + /// UTF-8 JSON containing exactly 128 integers in the range 0 through 255. + /// The validated keypair. + /// The input is empty. + /// The JSON or keypair representation is invalid. + public static BlsKeypair FromJsonArray(ReadOnlySpan utf8Json) + { + if (utf8Json.IsEmpty) + throw new ArgumentException("BLS keypair JSON cannot be empty.", nameof(utf8Json)); + + try + { + return FromJsonValues(JsonSerializer.Deserialize(utf8Json, WalletJsonContext.Default.Int32Array)); + } + catch (JsonException exception) + { + throw new FormatException("BLS keypair is not a valid UTF-8 JSON number array.", exception); + } + } + + private static BlsKeypair FromJsonValues(int[]? values) + { + if (values is null) + throw new FormatException($"BLS keypair JSON must contain exactly {KeypairLength} byte values."); + + byte[]? bytes = null; + try + { + if (values.Length != KeypairLength) + throw new FormatException($"BLS keypair JSON must contain exactly {KeypairLength} byte values."); + + bytes = new byte[KeypairLength]; + for (var i = 0; i < values.Length; i++) + { + if (values[i] is < byte.MinValue or > byte.MaxValue) + throw new FormatException($"BLS keypair JSON value at index {i} is outside the byte range."); + + bytes[i] = (byte)values[i]; + } + + try + { + return FromBytes(bytes); + } + catch (ArgumentException exception) + { + throw new FormatException("BLS keypair JSON contains an invalid keypair.", exception); + } + } + finally + { + if (bytes is not null) + CryptographicOperations.ZeroMemory(bytes); + Array.Clear(values); + } + } + + /// Signs a message with the exact pinned POP-ciphersuite signature DST. + /// The exact bytes to sign. + /// The validated compressed G2 signature. + /// The keypair has been disposed. + public BlsSignature Sign(ReadOnlySpan message) + { + lock (_secretGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return BlsSignature.FromValidated(BlsOperations.Sign(_secretKey, message)); + } + } + + /// Verifies a signature through this keypair's proof-of-possession-verified public key. + /// The validated compressed G2 signature. + /// The exact signed message. + /// only for a valid signature by this keypair. + /// is . + public bool Verify(BlsSignature signature, ReadOnlySpan message) + => PopVerifiedPublicKey.Verify(signature, message); + + /// Creates a proof bound to payload || compressed_public_key with the pinned POP DST. + /// The application payload to bind. + /// The validated compressed G2 proof. + /// The keypair has been disposed. + public BlsProofOfPossession CreateProofOfPossession(ReadOnlySpan payload) + { + lock (_secretGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return BlsProofOfPossession.FromValidated( + BlsOperations.CreateProofOfPossession(_secretKey, PublicKey.Bytes, payload)); + } + } + + /// Creates the vote-program proof bound to ALPENGLOW || vote_account. + /// The vote account that will store the BLS public key. + /// The validated compressed G2 proof. + /// The keypair has been disposed. + public BlsProofOfPossession CreateVoteProofOfPossession(PublicKey voteAccount) + { + Span payload = stackalloc byte[VoteProofPayloadLength]; + WriteVoteProofPayload(voteAccount, payload); + return CreateProofOfPossession(payload); + } + + /// + /// Exports a copy of the canonical 32-byte little-endian secret. The caller owns the returned + /// secret buffer and should clear it with . + /// + /// A new secret-key buffer. + /// The keypair has been disposed. + public byte[] ToSecretKeyBytes() + { + lock (_secretGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return [.. _secretKey]; + } + } + + /// + /// Exports the exact 128-byte Rust keypair representation: the 32-byte little-endian secret + /// followed by the derived 96-byte uncompressed public key. The caller must clear the result. + /// + /// A new secret-bearing keypair buffer. + /// The keypair has been disposed. + public byte[] ToBytes() + { + lock (_secretGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var bytes = new byte[KeypairLength]; + byte[]? publicKey = null; + try + { + _secretKey.CopyTo(bytes, 0); + publicKey = BlsOperations.DeriveUncompressedPublicKey(_secretKey); + publicKey.CopyTo(bytes, SecretKeyLength); + return bytes; + } + catch + { + CryptographicOperations.ZeroMemory(bytes); + throw; + } + finally + { + if (publicKey is not null) + CryptographicOperations.ZeroMemory(publicKey); + } + } + } + + /// + /// Exports the 128-byte keypair as the JSON number array used by the pinned Rust SDK. The + /// returned immutable string contains secret material and cannot be zeroed; prefer . + /// + /// A JSON array containing the secret and uncompressed public key. + /// The keypair has been disposed. + public string ToJsonArray() + { + var utf8Json = ToJsonUtf8Bytes(); + try + { + return Encoding.UTF8.GetString(utf8Json); + } + finally + { + CryptographicOperations.ZeroMemory(utf8Json); + } + } + + /// + /// Exports the 128-byte keypair as a zeroable UTF-8 JSON number array compatible with the + /// pinned Rust SDK. The caller owns the returned secret-bearing buffer and must clear it. + /// + /// A UTF-8 JSON buffer containing the secret and uncompressed public key. + /// The keypair has been disposed. + public byte[] ToJsonUtf8Bytes() + { + var bytes = ToBytes(); + int[]? values = null; + try + { + values = new int[KeypairLength]; + for (var i = 0; i < bytes.Length; i++) + values[i] = bytes[i]; + + return JsonSerializer.SerializeToUtf8Bytes(values, WalletJsonContext.Default.Int32Array); + } + finally + { + CryptographicOperations.ZeroMemory(bytes); + if (values is not null) + Array.Clear(values); + } + } + + /// Zeroes the in-memory BLS secret. Secret-dependent operations throw afterwards. + public void Dispose() + { + ClearSecret(); + GC.SuppressFinalize(this); + } + + /// Finalizer backstop that clears the managed secret when deterministic disposal was missed. + ~BlsKeypair() + { + ClearSecret(); + } + + internal static void WriteVoteProofPayload(PublicKey voteAccount, Span destination) + { + if (destination.Length != VoteProofPayloadLength) + throw new ArgumentException($"Vote proof payload must be {VoteProofPayloadLength} bytes.", nameof(destination)); + + VoteProofDomain.CopyTo(destination); + voteAccount.CopyTo(destination[VoteProofDomain.Length..]); + } + + private static BlsKeypair Create(byte[] ownedSecretKey) + { + try + { + return new BlsKeypair(ownedSecretKey); + } + catch + { + CryptographicOperations.ZeroMemory(ownedSecretKey); + throw; + } + } + + private void ClearSecret() + { + lock (_secretGate) + { + if (_disposed) + return; + + CryptographicOperations.ZeroMemory(_secretKey); + _disposed = true; + } + } +} diff --git a/src/SolSharp.Wallet/BlsOperations.cs b/src/SolSharp.Wallet/BlsOperations.cs new file mode 100644 index 0000000..7971736 --- /dev/null +++ b/src/SolSharp.Wallet/BlsOperations.cs @@ -0,0 +1,352 @@ +using System.Security.Cryptography; +using Backend = Nethermind.Crypto.Bls; + +namespace SolSharp.Wallet; + +internal static class BlsOperations +{ + internal const int SecretKeyLength = 32; + internal const int PublicKeyLength = 48; + internal const int SignatureLength = 96; + + private static ReadOnlySpan SignatureDomain => + "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"u8; + + private static ReadOnlySpan ProofOfPossessionDomain => + "BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"u8; + + internal static bool TryDecodeCanonicalBase64(ReadOnlySpan base64, Span destination) + { + if (base64.Length != checked(destination.Length / 3 * 4)) + return false; + + foreach (var value in base64) + { + if (!IsBase64Alphabet(value)) + return false; + } + + return Convert.TryFromBase64Chars(base64, destination, out var bytesWritten) && + bytesWritten == destination.Length; + } + + internal static byte[] DeriveSecretKey(ReadOnlySpan inputKeyMaterial) + { + if (inputKeyMaterial.Length < SecretKeyLength) + { + throw new ArgumentException( + $"BLS input key material must contain at least {SecretKeyLength} bytes.", + nameof(inputKeyMaterial)); + } + + var result = new byte[SecretKeyLength]; + try + { + var secretKey = new Backend.SecretKey(result); + secretKey.Keygen(inputKeyMaterial); + return result; + } + catch + { + CryptographicOperations.ZeroMemory(result); + throw; + } + } + + internal static bool IsCanonicalSecretKey(ReadOnlySpan secretKey) + { + if (secretKey.Length != SecretKeyLength) + return false; + + Span decoded = stackalloc byte[SecretKeyLength]; + try + { + var backendKey = new Backend.SecretKey(decoded); + backendKey.FromLendian(secretKey); + return true; + } + catch (Backend.BlsException) + { + return false; + } + finally + { + CryptographicOperations.ZeroMemory(decoded); + } + } + + internal static byte[] DerivePublicKey(ReadOnlySpan secretKey) + { + Span decoded = stackalloc byte[SecretKeyLength]; + try + { + var backendKey = new Backend.SecretKey(decoded); + backendKey.FromLendian(secretKey); + var publicKey = new Backend.P1(backendKey); + return publicKey.Compress(); + } + finally + { + CryptographicOperations.ZeroMemory(decoded); + } + } + + internal static byte[] DeriveUncompressedPublicKey(ReadOnlySpan secretKey) + { + Span decoded = stackalloc byte[SecretKeyLength]; + try + { + var backendKey = new Backend.SecretKey(decoded); + backendKey.FromLendian(secretKey); + var publicKey = new Backend.P1(backendKey); + return publicKey.Serialize(); + } + finally + { + CryptographicOperations.ZeroMemory(decoded); + } + } + + internal static byte[] Sign(ReadOnlySpan secretKey, ReadOnlySpan message) + { + Span decoded = stackalloc byte[SecretKeyLength]; + try + { + var backendKey = new Backend.SecretKey(decoded); + backendKey.FromLendian(secretKey); + var signature = new Backend.P2().HashTo(message, SignatureDomain).SignWith(backendKey); + return signature.Compress(); + } + finally + { + CryptographicOperations.ZeroMemory(decoded); + } + } + + internal static byte[] CreateProofOfPossession( + ReadOnlySpan secretKey, + ReadOnlySpan publicKey, + ReadOnlySpan payload) + { + var boundPayload = GC.AllocateUninitializedArray(checked(payload.Length + PublicKeyLength)); + payload.CopyTo(boundPayload); + publicKey.CopyTo(boundPayload.AsSpan(payload.Length)); + + Span decoded = stackalloc byte[SecretKeyLength]; + try + { + var backendKey = new Backend.SecretKey(decoded); + backendKey.FromLendian(secretKey); + var proof = new Backend.P2().HashTo(boundPayload, ProofOfPossessionDomain).SignWith(backendKey); + return proof.Compress(); + } + finally + { + CryptographicOperations.ZeroMemory(decoded); + CryptographicOperations.ZeroMemory(boundPayload); + } + } + + internal static bool IsValidPublicKey(ReadOnlySpan compressed) => + IsValidG1Point(compressed); + + internal static bool IsValidSignature(ReadOnlySpan compressed) => + GetG2ValidationResult(compressed) == BlsPointValidationResult.Valid; + + internal static byte[] AggregatePublicKeys(IReadOnlyList publicKeys) + { + Span point = stackalloc long[Backend.P1Affine.Sz]; + try + { + var decoded = new Backend.P1Affine(point); + DecodePublicKey(publicKeys[0].Bytes, decoded); + var aggregate = new Backend.P1(decoded); + for (var i = 1; i < publicKeys.Count; i++) + { + DecodePublicKey(publicKeys[i].Bytes, decoded); + aggregate.Add(decoded); + } + + return aggregate.Compress(); + } + catch (Backend.BlsException exception) + { + throw new CryptographicException("The native BLS backend rejected a validated public key during aggregation.", exception); + } + } + + internal static byte[] AggregateSignatures(IReadOnlyList signatures) + { + Span point = stackalloc long[Backend.P2Affine.Sz]; + try + { + var decoded = new Backend.P2Affine(point); + DecodeSignature(signatures[0].Bytes, decoded); + var aggregate = new Backend.P2(decoded); + for (var i = 1; i < signatures.Count; i++) + { + DecodeSignature(signatures[i].Bytes, decoded); + aggregate.Add(decoded); + } + + return aggregate.Compress(); + } + catch (Backend.BlsException exception) + { + throw new CryptographicException("The native BLS backend rejected a validated signature during aggregation.", exception); + } + } + + internal static BlsPointValidationResult GetG2ValidationResult(ReadOnlySpan compressed) + { + if (compressed.Length != SignatureLength) + return BlsPointValidationResult.BadEncoding; + + Span point = stackalloc long[Backend.P2Affine.Sz]; + try + { + var decoded = new Backend.P2Affine(point); + if (!decoded.TryDecode(compressed, out _)) + return BlsPointValidationResult.BadEncoding; + if (!decoded.OnCurve()) + return BlsPointValidationResult.NotOnCurve; + if (!decoded.InGroup()) + return BlsPointValidationResult.NotInGroup; + return decoded.IsInf() + ? BlsPointValidationResult.Infinity + : BlsPointValidationResult.Valid; + } + catch (Backend.BlsException) + { + return BlsPointValidationResult.BadEncoding; + } + } + + internal static bool VerifySignature( + ReadOnlySpan publicKey, + ReadOnlySpan signature, + ReadOnlySpan message) => + Verify(publicKey, signature, message, SignatureDomain); + + internal static bool VerifyProofOfPossession( + ReadOnlySpan publicKey, + ReadOnlySpan proof, + ReadOnlySpan payload) + { + var boundPayload = GC.AllocateUninitializedArray(checked(payload.Length + PublicKeyLength)); + payload.CopyTo(boundPayload); + publicKey.CopyTo(boundPayload.AsSpan(payload.Length)); + try + { + return Verify(publicKey, proof, boundPayload, ProofOfPossessionDomain); + } + finally + { + CryptographicOperations.ZeroMemory(boundPayload); + } + } + + private static bool Verify( + ReadOnlySpan publicKey, + ReadOnlySpan signature, + ReadOnlySpan message, + ReadOnlySpan domain) + { + if (publicKey.Length != PublicKeyLength || signature.Length != SignatureLength) + return false; + + Span publicKeyPoint = stackalloc long[Backend.P1Affine.Sz]; + Span signaturePoint = stackalloc long[Backend.P2Affine.Sz]; + try + { + var decodedPublicKey = new Backend.P1Affine(publicKeyPoint); + var decodedSignature = new Backend.P2Affine(signaturePoint); + if (!decodedPublicKey.TryDecode(publicKey, out _) || + !decodedPublicKey.OnCurve() || + !decodedPublicKey.InGroup() || + decodedPublicKey.IsInf() || + !decodedSignature.TryDecode(signature, out _) || + !decodedSignature.OnCurve() || + !decodedSignature.InGroup() || + decodedSignature.IsInf()) + { + return false; + } + + var hash = new Backend.P2().HashTo(message, domain); + var left = new Backend.PT(decodedSignature, Backend.P1Affine.Generator()); + var right = new Backend.PT(hash.ToAffine(), decodedPublicKey); + return Backend.PT.FinalVerify(left, right); + } + catch (Backend.BlsException) + { + return false; + } + } + + private static bool IsValidG1Point(ReadOnlySpan compressed) + { + if (compressed.Length != PublicKeyLength) + return false; + + Span point = stackalloc long[Backend.P1Affine.Sz]; + try + { + var decoded = new Backend.P1Affine(point); + return decoded.TryDecode(compressed, out _) && + decoded.OnCurve() && + decoded.InGroup() && + !decoded.IsInf(); + } + catch (Backend.BlsException) + { + return false; + } + } + + private static void DecodePublicKey(ReadOnlySpan compressed, Backend.P1Affine decoded) + { + if (!decoded.TryDecode(compressed, out _) || + !decoded.OnCurve() || + !decoded.InGroup() || + decoded.IsInf()) + { + throw new CryptographicException("A validated BLS public key no longer satisfies its point invariant."); + } + } + + private static void DecodeSignature(ReadOnlySpan compressed, Backend.P2Affine decoded) + { + if (!decoded.TryDecode(compressed, out _) || + !decoded.OnCurve() || + !decoded.InGroup() || + decoded.IsInf()) + { + throw new CryptographicException("A validated BLS signature no longer satisfies its point invariant."); + } + } + + private static bool IsBase64Alphabet(char value) => + value is >= 'A' and <= 'Z' or + >= 'a' and <= 'z' or + >= '0' and <= '9' or + '+' or '/'; +} + +internal enum BlsPointValidationResult +{ + /// The point is valid. + Valid, + + /// The byte representation cannot be decoded. + BadEncoding, + + /// The decoded point is not on the curve. + NotOnCurve, + + /// The decoded point is outside the prime-order subgroup. + NotInGroup, + + /// The decoded point is the point at infinity. + Infinity +} diff --git a/src/SolSharp.Wallet/BlsPopVerifiedPublicKey.cs b/src/SolSharp.Wallet/BlsPopVerifiedPublicKey.cs new file mode 100644 index 0000000..09ee826 --- /dev/null +++ b/src/SolSharp.Wallet/BlsPopVerifiedPublicKey.cs @@ -0,0 +1,32 @@ +namespace SolSharp.Wallet; + +/// +/// A BLS public key whose proof of possession was cryptographically verified before construction. +/// This provenance wrapper is required by the safe public-key aggregation API to prevent rogue-key attacks. +/// +public sealed class BlsPopVerifiedPublicKey +{ + internal BlsPopVerifiedPublicKey(BlsPublicKey publicKey) + { + PublicKey = publicKey; + } + + /// The underlying canonical, subgroup-checked public key. + public BlsPublicKey PublicKey { get; } + + /// + /// Verifies a minimal-public-key-size BLS signature with the exact pinned Solana signature DST. + /// Signature verification is exposed only after proof-of-possession provenance is established. + /// + /// The validated compressed G2 signature. + /// The exact signed message. + /// only for a valid signature by this key. + /// is . + public bool Verify(BlsSignature signature, ReadOnlySpan message) + { + ArgumentNullException.ThrowIfNull(signature); + return BlsOperations.VerifySignature(Bytes, signature.Bytes, message); + } + + internal ReadOnlySpan Bytes => PublicKey.Bytes; +} diff --git a/src/SolSharp.Wallet/BlsProofOfPossession.cs b/src/SolSharp.Wallet/BlsProofOfPossession.cs new file mode 100644 index 0000000..84e034b --- /dev/null +++ b/src/SolSharp.Wallet/BlsProofOfPossession.cs @@ -0,0 +1,117 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SolSharp.Wallet; + +/// +/// A canonical, subgroup-checked 96-byte G2 proof of possession using the pinned Solana POP scheme. +/// +public sealed class BlsProofOfPossession : IEquatable +{ + /// The compressed proof length. + public const int Length = BlsOperations.SignatureLength; + + private readonly byte[] _bytes; + + private BlsProofOfPossession(ReadOnlySpan bytes) + { + _bytes = bytes.ToArray(); + } + + /// Parses a compressed G2 proof and performs canonical, curve, subgroup, and infinity checks. + /// The exact 96-byte compressed point. + /// The validated proof. + /// The value is not a canonical non-infinity G2 point. + public static BlsProofOfPossession Parse(ReadOnlySpan compressed) + { + if (!BlsOperations.IsValidSignature(compressed)) + throw new ArgumentException("BLS proof must be a canonical non-infinity G2 subgroup point.", nameof(compressed)); + + return new BlsProofOfPossession(compressed); + } + + /// Parses the standard base64 text emitted by . + /// Exactly 128 ASCII base64 characters encoding a 96-byte compressed proof. + /// The validated proof. + /// The value is null, empty, or whitespace. + /// The text or decoded point is invalid. + public static BlsProofOfPossession Parse(string base64) + { + ArgumentException.ThrowIfNullOrWhiteSpace(base64); + Span compressed = stackalloc byte[Length]; + if (!BlsOperations.TryDecodeCanonicalBase64(base64, compressed)) + throw new FormatException("BLS proof is not canonical fixed-length base64."); + + try + { + return Parse(compressed); + } + catch (ArgumentException exception) + { + throw new FormatException("BLS proof is not a valid base64 compressed G2 point.", exception); + } + } + + /// Attempts to parse and fully validate a compressed G2 proof. + /// The candidate compressed point. + /// The validated proof on success. + /// when the point is canonical, in G2, and not infinity. + public static bool TryParse( + ReadOnlySpan compressed, + [NotNullWhen(true)] out BlsProofOfPossession? proof) + { + if (!BlsOperations.IsValidSignature(compressed)) + { + proof = null; + return false; + } + + proof = new BlsProofOfPossession(compressed); + return true; + } + + /// Attempts to parse the standard base64 representation. + /// The candidate base64 text, or . + /// The validated proof on success. + /// when the text and point are valid. + public static bool TryParse( + string? base64, + [NotNullWhen(true)] out BlsProofOfPossession? proof) + { + try + { + proof = string.IsNullOrWhiteSpace(base64) ? null : Parse(base64); + return proof is not null; + } + catch (FormatException) + { + proof = null; + return false; + } + } + + /// Returns a new array containing the compressed 96-byte proof. + /// A defensive copy of the compressed proof. + public byte[] ToBytes() => [.. _bytes]; + + /// + public bool Equals(BlsProofOfPossession? other) => other is not null && _bytes.AsSpan().SequenceEqual(other._bytes); + + /// + public override bool Equals(object? obj) => obj is BlsProofOfPossession other && Equals(other); + + /// + public override int GetHashCode() + { + var hash = default(HashCode); + foreach (var value in _bytes) + hash.Add(value); + return hash.ToHashCode(); + } + + /// + public override string ToString() => Convert.ToBase64String(_bytes); + + internal ReadOnlySpan Bytes => _bytes; + + internal static BlsProofOfPossession FromValidated(ReadOnlySpan compressed) => new(compressed); +} diff --git a/src/SolSharp.Wallet/BlsPublicKey.cs b/src/SolSharp.Wallet/BlsPublicKey.cs new file mode 100644 index 0000000..7fd83ad --- /dev/null +++ b/src/SolSharp.Wallet/BlsPublicKey.cs @@ -0,0 +1,165 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using SolSharp.Core.Primitives; + +namespace SolSharp.Wallet; + +/// +/// A canonical, subgroup-checked compressed BLS12-381 public key in the 48-byte G1 representation +/// used by the pinned Solana SDK. +/// +public sealed class BlsPublicKey : IEquatable +{ + /// The compressed public-key length. + public const int Length = BlsOperations.PublicKeyLength; + + private readonly byte[] _bytes; + + private BlsPublicKey(ReadOnlySpan bytes) + { + _bytes = bytes.ToArray(); + } + + /// Parses a compressed G1 point and rejects malformed, off-curve, wrong-subgroup, and infinity encodings. + /// The exact 48-byte compressed point. + /// The validated public key. + /// The value is not a canonical non-infinity G1 point. + public static BlsPublicKey Parse(ReadOnlySpan compressed) + { + if (!BlsOperations.IsValidPublicKey(compressed)) + throw new ArgumentException("BLS public key must be a canonical non-infinity G1 subgroup point.", nameof(compressed)); + + return new BlsPublicKey(compressed); + } + + /// Parses the standard base64 text emitted by . + /// Exactly 64 ASCII base64 characters encoding a 48-byte compressed public key. + /// The validated public key. + /// The value is null, empty, or whitespace. + /// The text or decoded point is invalid. + public static BlsPublicKey Parse(string base64) + { + ArgumentException.ThrowIfNullOrWhiteSpace(base64); + Span compressed = stackalloc byte[Length]; + if (!BlsOperations.TryDecodeCanonicalBase64(base64, compressed)) + throw new FormatException("BLS public key is not canonical fixed-length base64."); + + try + { + return Parse(compressed); + } + catch (ArgumentException exception) + { + throw new FormatException("BLS public key is not a valid base64 compressed G1 point.", exception); + } + } + + /// Attempts to parse and fully validate a compressed G1 public key. + /// The candidate compressed point. + /// The validated public key on success. + /// when the point is canonical, in G1, and not infinity. + public static bool TryParse( + ReadOnlySpan compressed, + [NotNullWhen(true)] out BlsPublicKey? publicKey) + { + if (!BlsOperations.IsValidPublicKey(compressed)) + { + publicKey = null; + return false; + } + + publicKey = new BlsPublicKey(compressed); + return true; + } + + /// Attempts to parse the standard base64 representation. + /// The candidate base64 text, or . + /// The validated public key on success. + /// when the text and point are valid. + public static bool TryParse( + string? base64, + [NotNullWhen(true)] out BlsPublicKey? publicKey) + { + try + { + publicKey = string.IsNullOrWhiteSpace(base64) ? null : Parse(base64); + return publicKey is not null; + } + catch (FormatException) + { + publicKey = null; + return false; + } + } + + /// Returns a new array containing the compressed 48-byte public key. + /// A defensive copy of the compressed public key. + public byte[] ToBytes() => [.. _bytes]; + + /// + /// Verifies a proof of possession using the pinned POP DST and the Solana binding + /// payload || compressed_public_key. + /// + /// The validated compressed G2 proof. + /// The application payload bound before the public key. + /// only for a valid proof by this key. + /// is . + public bool VerifyProofOfPossession(BlsProofOfPossession proof, ReadOnlySpan payload) + { + ArgumentNullException.ThrowIfNull(proof); + return BlsOperations.VerifyProofOfPossession(_bytes, proof.Bytes, payload); + } + + /// + /// Verifies a proof of possession and returns the typed wrapper required for safe public-key aggregation. + /// + /// The validated compressed G2 proof. + /// The exact application payload used when the proof was created. + /// This public key wrapped with verified proof-of-possession provenance. + /// is . + /// The proof is invalid for this key and payload. + public BlsPopVerifiedPublicKey VerifyAndWrapProofOfPossession( + BlsProofOfPossession proof, + ReadOnlySpan payload) + { + ArgumentNullException.ThrowIfNull(proof); + if (!BlsOperations.VerifyProofOfPossession(_bytes, proof.Bytes, payload)) + throw new CryptographicException("The BLS proof of possession is invalid for this public key and payload."); + + return new BlsPopVerifiedPublicKey(this); + } + + /// Verifies the vote-program proof bound to ALPENGLOW || vote_account. + /// The validated compressed proof. + /// The vote account to which the proof must be bound. + /// only for the exact vote-account binding. + /// is . + public bool VerifyVoteProofOfPossession(BlsProofOfPossession proof, PublicKey voteAccount) + { + Span payload = stackalloc byte[BlsKeypair.VoteProofPayloadLength]; + BlsKeypair.WriteVoteProofPayload(voteAccount, payload); + return VerifyProofOfPossession(proof, payload); + } + + /// + public bool Equals(BlsPublicKey? other) => other is not null && _bytes.AsSpan().SequenceEqual(other._bytes); + + /// + public override bool Equals(object? obj) => obj is BlsPublicKey other && Equals(other); + + /// + public override int GetHashCode() + { + var hash = default(HashCode); + foreach (var value in _bytes) + hash.Add(value); + return hash.ToHashCode(); + } + + /// + public override string ToString() => Convert.ToBase64String(_bytes); + + internal ReadOnlySpan Bytes => _bytes; + + internal static BlsPublicKey FromValidated(ReadOnlySpan compressed) => new(compressed); +} diff --git a/src/SolSharp.Wallet/BlsSignature.cs b/src/SolSharp.Wallet/BlsSignature.cs new file mode 100644 index 0000000..f6331a9 --- /dev/null +++ b/src/SolSharp.Wallet/BlsSignature.cs @@ -0,0 +1,145 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; + +namespace SolSharp.Wallet; + +/// +/// A canonical, subgroup-checked compressed BLS12-381 signature in the 96-byte G2 representation +/// used by the pinned Solana SDK. +/// +public sealed class BlsSignature : IEquatable +{ + /// The compressed signature length. + public const int Length = BlsOperations.SignatureLength; + + private readonly byte[] _bytes; + + private BlsSignature(ReadOnlySpan bytes) + { + _bytes = bytes.ToArray(); + } + + /// Parses a compressed G2 point and rejects malformed, off-curve, wrong-subgroup, and infinity encodings. + /// The exact 96-byte compressed point. + /// The validated signature. + /// The value is not a canonical non-infinity G2 point. + public static BlsSignature Parse(ReadOnlySpan compressed) + { + if (!BlsOperations.IsValidSignature(compressed)) + throw new ArgumentException("BLS signature must be a canonical non-infinity G2 subgroup point.", nameof(compressed)); + + return new BlsSignature(compressed); + } + + /// Parses the standard base64 text emitted by . + /// Exactly 128 ASCII base64 characters encoding a 96-byte compressed signature. + /// The validated signature. + /// The value is null, empty, or whitespace. + /// The text or decoded point is invalid. + public static BlsSignature Parse(string base64) + { + ArgumentException.ThrowIfNullOrWhiteSpace(base64); + Span compressed = stackalloc byte[Length]; + if (!BlsOperations.TryDecodeCanonicalBase64(base64, compressed)) + throw new FormatException("BLS signature is not canonical fixed-length base64."); + + try + { + return Parse(compressed); + } + catch (ArgumentException exception) + { + throw new FormatException("BLS signature is not a valid base64 compressed G2 point.", exception); + } + } + + /// Attempts to parse and fully validate a compressed G2 signature. + /// The candidate compressed point. + /// The validated signature on success. + /// when the point is canonical, in G2, and not infinity. + public static bool TryParse( + ReadOnlySpan compressed, + [NotNullWhen(true)] out BlsSignature? signature) + { + if (!BlsOperations.IsValidSignature(compressed)) + { + signature = null; + return false; + } + + signature = new BlsSignature(compressed); + return true; + } + + /// Attempts to parse the standard base64 representation. + /// The candidate base64 text, or . + /// The validated signature on success. + /// when the text and point are valid. + public static bool TryParse( + string? base64, + [NotNullWhen(true)] out BlsSignature? signature) + { + try + { + signature = string.IsNullOrWhiteSpace(base64) ? null : Parse(base64); + return signature is not null; + } + catch (FormatException) + { + signature = null; + return false; + } + } + + /// Returns a new array containing the compressed 96-byte signature. + /// A defensive copy of the compressed signature. + public byte[] ToBytes() => [.. _bytes]; + + /// + /// Aggregates one or more subgroup-checked signatures with native BLS12-381 group addition. + /// Duplicate signatures are included repeatedly, matching the pinned Solana SDK. + /// + /// The nonempty signatures to aggregate. + /// The canonical compressed aggregate signature. + /// is . + /// + /// The collection is empty, contains , or aggregates to the point at infinity. + /// + /// The native BLS backend rejects a validated input. + public static BlsSignature Aggregate(IReadOnlyList signatures) + { + ArgumentNullException.ThrowIfNull(signatures); + if (signatures.Count == 0) + throw new ArgumentException("At least one BLS signature is required for aggregation.", nameof(signatures)); + if (signatures.Any(signature => signature is null)) + throw new ArgumentException("BLS signature aggregation cannot contain null entries.", nameof(signatures)); + + var aggregate = BlsOperations.AggregateSignatures(signatures); + if (!BlsOperations.IsValidSignature(aggregate)) + throw new ArgumentException("BLS signatures must not aggregate to the point at infinity.", nameof(signatures)); + + return FromValidated(aggregate); + } + + /// + public bool Equals(BlsSignature? other) => other is not null && _bytes.AsSpan().SequenceEqual(other._bytes); + + /// + public override bool Equals(object? obj) => obj is BlsSignature other && Equals(other); + + /// + public override int GetHashCode() + { + var hash = default(HashCode); + foreach (var value in _bytes) + hash.Add(value); + return hash.ToHashCode(); + } + + /// + public override string ToString() => Convert.ToBase64String(_bytes); + + internal ReadOnlySpan Bytes => _bytes; + + internal static BlsSignature FromValidated(ReadOnlySpan compressed) => new(compressed); +} diff --git a/src/SolSharp.Wallet/Ed25519Curve.cs b/src/SolSharp.Wallet/Ed25519Curve.cs index f4312b6..cd67502 100644 --- a/src/SolSharp.Wallet/Ed25519Curve.cs +++ b/src/SolSharp.Wallet/Ed25519Curve.cs @@ -22,16 +22,16 @@ public static bool IsOnCurve(ReadOnlySpan encoded) // y is the low 255 bits reduced mod p; the top bit is the sign of x and is ignored here. var y = (new BigInteger(encoded, isUnsigned: true, isBigEndian: false) & YMask) % P; - var y2 = y * y % P; + var y2 = (y * y) % P; var u = Mod(y2 - 1); - var v = Mod(D * y2 + 1); + var v = Mod((D * y2) + 1); if (v.IsZero) return false; // x^2 = u / v must be a square. For p = 5 (mod 8) the candidate's square is +/- (u/v), // so v * x^2 lands on +/- u exactly when u/v is a square. - var x = BigInteger.ModPow(u * BigInteger.ModPow(v, P - 2, P) % P, SqrtExponent, P); - var check = x * x % P * v % P; + var x = BigInteger.ModPow((u * BigInteger.ModPow(v, P - 2, P)) % P, SqrtExponent, P); + var check = (((x * x) % P) * v) % P; return check == u || check == Mod(-u); } diff --git a/src/SolSharp.Wallet/Keypair.Parsing.cs b/src/SolSharp.Wallet/Keypair.Parsing.cs index 81ea2e5..70144e0 100644 --- a/src/SolSharp.Wallet/Keypair.Parsing.cs +++ b/src/SolSharp.Wallet/Keypair.Parsing.cs @@ -167,22 +167,51 @@ public static Keypair FromJsonArray(string json) if (values is null) throw new FormatException("Key JSON must be an array, not null."); - var bytes = new byte[values.Length]; - for (var i = 0; i < values.Length; i++) + byte[]? bytes = null; + try { - if (values[i] is < 0 or > byte.MaxValue) - throw new FormatException($"Key JSON value at index {i} is outside the byte range 0-255: {values[i]}."); + bytes = new byte[values.Length]; + for (var i = 0; i < values.Length; i++) + { + if (values[i] is < 0 or > byte.MaxValue) + throw new FormatException($"Key JSON value at index {i} is outside the byte range 0-255: {values[i]}."); + + bytes[i] = (byte)values[i]; + } - bytes[i] = (byte)values[i]; + return FromDecoded(bytes, "JSON key array"); + } + finally + { + if (bytes is not null) + CryptographicOperations.ZeroMemory(bytes); + Array.Clear(values); } + } + /// + /// Exports the 64-byte secret key as the JSON number array used by solana-keygen id.json. + /// The returned immutable string contains secret material and cannot be zeroed; prefer + /// when the receiving API accepts bytes. + /// + /// A JSON array containing the 32-byte seed followed by the 32-byte public key. + /// The keypair has already been disposed. + public string ToJsonArray() + { + var values = new int[SecretKeyLength]; + byte[]? bytes = null; try { - return FromDecoded(bytes, "JSON key array"); + bytes = ToBytes(); + for (var i = 0; i < bytes.Length; i++) + values[i] = bytes[i]; + + return JsonSerializer.Serialize(values, WalletJsonContext.Default.Int32Array); } finally { - CryptographicOperations.ZeroMemory(bytes); + if (bytes is not null) + CryptographicOperations.ZeroMemory(bytes); Array.Clear(values); } } @@ -207,14 +236,27 @@ public static Keypair FromJsonArray(string json) } private static byte[]? TryDecodeBase58(string text) - => Base58.TryDecode(text, out var bytes) && bytes.Length is SeedLength or SecretKeyLength ? bytes : null; + { + if (!Base58.TryDecode(text, out var bytes)) + return null; + + if (bytes.Length is SeedLength or SecretKeyLength) + return bytes; + + CryptographicOperations.ZeroMemory(bytes); + return null; + } private static byte[]? TryDecodeBase64(string text) { try { var bytes = Convert.FromBase64String(text); - return bytes.Length is SeedLength or SecretKeyLength ? bytes : null; + if (bytes.Length is SeedLength or SecretKeyLength) + return bytes; + + CryptographicOperations.ZeroMemory(bytes); + return null; } catch (FormatException) { diff --git a/src/SolSharp.Wallet/Keypair.cs b/src/SolSharp.Wallet/Keypair.cs index e00b794..8e43865 100644 --- a/src/SolSharp.Wallet/Keypair.cs +++ b/src/SolSharp.Wallet/Keypair.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using Org.BouncyCastle.Math.EC.Rfc8032; +using SolSharp.Core.Encoding; using SolSharp.Core.Primitives; namespace SolSharp.Wallet; @@ -17,6 +18,7 @@ public sealed partial class Keypair : ISigner, IDisposable /// Length in bytes of a Solana secret key: the 32-byte seed followed by the 32-byte public key. public const int SecretKeyLength = 64; + private readonly object _secretGate = new(); private readonly byte[] _seed; private bool _disposed; @@ -67,7 +69,7 @@ public static Keypair FromSecretKey(ReadOnlySpan secretKey) Span derived = stackalloc byte[PublicKey.Length]; keypair.PublicKey.CopyTo(derived); - if (!secretKey[SeedLength..].SequenceEqual(derived)) + if (!CryptographicOperations.FixedTimeEquals(secretKey[SeedLength..], derived)) { keypair.Dispose(); throw new ArgumentException("Secret key's public-key half does not match its seed.", nameof(secretKey)); @@ -82,22 +84,94 @@ public static Keypair FromSecretKey(ReadOnlySpan secretKey) /// The keypair has already been disposed. public byte[] Sign(ReadOnlySpan message) { - ObjectDisposedException.ThrowIf(_disposed, this); + lock (_secretGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // The span overload signs without copying the message - the only allocation is the signature. + var signature = new byte[Ed25519.SignatureSize]; + Ed25519.Sign(_seed, message, signature); + return signature; + } + } + + /// Signs and returns the signature as a typed Solana value. + /// The bytes to sign; for a transaction, the serialized message. + /// The strict Ed25519 signature. + /// The keypair has already been disposed. + public Signature SignSignature(ReadOnlySpan message) + { + lock (_secretGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + Span signature = stackalloc byte[Signature.Length]; + Ed25519.Sign(_seed, message, signature); + return new Signature(signature); + } + } + + /// + /// Exports the Solana 64-byte secret-key representation: the 32-byte Ed25519 seed followed by + /// the derived 32-byte public key. The returned array contains secret material; the caller owns + /// it and should clear it with as + /// soon as it is no longer needed. + /// + /// A new 64-byte secret-key array. + /// The keypair has already been disposed. + public byte[] ToBytes() + { + lock (_secretGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var bytes = new byte[SecretKeyLength]; + _seed.CopyTo(bytes, 0); + PublicKey.CopyTo(bytes.AsSpan(SeedLength)); + return bytes; + } + } + + /// + /// Exports a copy of the 32-byte Ed25519 seed. The returned array contains secret material; the + /// caller owns it and should clear it with + /// as soon as it is no longer needed. + /// + /// A new 32-byte seed array. + /// The keypair has already been disposed. + public byte[] ToSeedBytes() + { + lock (_secretGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return [.. _seed]; + } + } - // The span overload signs without copying the message - the only allocation is the signature. - var signature = new byte[Ed25519.SignatureSize]; - Ed25519.Sign(_seed, message, signature); - return signature; + /// + /// Exports the Solana 64-byte secret key as base58, matching wallet exports and the Rust SDK's + /// to_base58_string. The returned immutable string contains secret material and cannot be + /// zeroed; prefer when the receiving API accepts bytes. + /// + /// The base58-encoded 64-byte secret key. + /// The keypair has already been disposed. + public string ToBase58String() + { + var bytes = ToBytes(); + try + { + return Base58.Encode(bytes); + } + finally + { + CryptographicOperations.ZeroMemory(bytes); + } } /// Zeroes the in-memory secret seed. Signing after disposal throws. public void Dispose() { - if (_disposed) - return; - - CryptographicOperations.ZeroMemory(_seed); - _disposed = true; + ClearSecret(); GC.SuppressFinalize(this); } @@ -107,7 +181,18 @@ public void Dispose() /// ~Keypair() { - if (!_disposed) + ClearSecret(); + } + + private void ClearSecret() + { + lock (_secretGate) + { + if (_disposed) + return; + CryptographicOperations.ZeroMemory(_seed); + _disposed = true; + } } } diff --git a/src/SolSharp.Wallet/NullSigner.cs b/src/SolSharp.Wallet/NullSigner.cs new file mode 100644 index 0000000..953d27b --- /dev/null +++ b/src/SolSharp.Wallet/NullSigner.cs @@ -0,0 +1,19 @@ +using SolSharp.Core.Primitives; + +namespace SolSharp.Wallet; + +/// +/// A placeholder for an absent required signer. It identifies the expected +/// public key and always returns the all-zero signature used by Solana partially signed transactions. +/// +/// The required signer key whose signature is not yet available. +public sealed class NullSigner(PublicKey publicKey) : ISigner +{ + /// + public PublicKey PublicKey { get; } = publicKey; + + /// Returns an all-zero Ed25519 signature placeholder. + /// The message is intentionally not inspected. + /// A new all-zero 64-byte signature. + public byte[] Sign(ReadOnlySpan message) => new byte[Signature.Length]; +} diff --git a/src/SolSharp.Wallet/OffchainMessage.cs b/src/SolSharp.Wallet/OffchainMessage.cs new file mode 100644 index 0000000..9a7a01e --- /dev/null +++ b/src/SolSharp.Wallet/OffchainMessage.cs @@ -0,0 +1,258 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; +using System.Text; +using SolSharp.Core.Primitives; + +namespace SolSharp.Wallet; + +/// The payload encoding selected for a version-0 Solana off-chain message. +public enum OffchainMessageFormat : byte +{ + /// Printable ASCII bytes in the ledger-sized payload range. + RestrictedAscii = 0, + + /// Valid UTF-8 bytes in the ledger-sized payload range. + LimitedUtf8 = 1, + + /// Valid UTF-8 bytes above the ledger-sized range. + ExtendedUtf8 = 2 +} + +/// +/// A version-0 Solana off-chain message. Its domain-separated bytes can be signed by a Solana key +/// without constructing a transaction or granting any on-chain authority. +/// +public sealed class OffchainMessage : IEquatable +{ + /// The only version supported by the pinned Rust contract. + public const byte CurrentVersion = 0; + + /// The signing-domain plus version header length. + public const int HeaderLength = 17; + + /// The largest version-0 payload representable by the wire format. + public const int MaxMessageLength = 65_515; + + /// The largest version-0 payload accepted in a Solana ledger packet. + public const int MaxLedgerMessageLength = 1_212; + + private const int VersionHeaderLength = 3; + + private static readonly byte[] SigningDomain = + [ + 0xFF, + 0x73, + 0x6F, + 0x6C, + 0x61, + 0x6E, + 0x61, + 0x20, + 0x6F, + 0x66, + 0x66, + 0x63, + 0x68, + 0x61, + 0x69, + 0x6E + ]; + + private static readonly UTF8Encoding StrictUtf8 = new(false, true); + + private readonly byte[] _message; + + private OffchainMessage(OffchainMessageFormat format, ReadOnlySpan message) + { + Format = format; + _message = message.ToArray(); + } + + /// The off-chain message version. + [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "The wire version belongs to each parsed message instance.")] + public byte Version => CurrentVersion; + + /// The payload format encoded in the wire header. + public OffchainMessageFormat Format { get; } + + /// The payload length in bytes. + public int MessageLength => _message.Length; + + /// Creates a version-0 off-chain message and selects its canonical payload format. + /// A non-empty printable-ASCII or valid-UTF-8 payload. + /// The validated off-chain message. + /// is empty or is not valid UTF-8. + /// exceeds bytes. + public static OffchainMessage Create(ReadOnlySpan message) => Create(CurrentVersion, message); + + /// Creates an off-chain message for an explicitly selected wire version. + /// The wire version; only is currently defined. + /// A non-empty printable-ASCII or valid-UTF-8 payload. + /// The validated off-chain message. + /// + /// is unsupported or exceeds + /// bytes. + /// + /// is empty or is not valid UTF-8. + public static OffchainMessage Create(byte version, ReadOnlySpan message) + { + if (version != CurrentVersion) + throw new ArgumentOutOfRangeException(nameof(version), version, "Only off-chain message version 0 is supported."); + if (message.IsEmpty) + throw new ArgumentException("An off-chain message cannot be empty.", nameof(message)); + if (message.Length > MaxMessageLength) + throw new ArgumentOutOfRangeException(nameof(message), message.Length, $"An off-chain message cannot exceed {MaxMessageLength} bytes."); + + OffchainMessageFormat format; + if (message.Length <= MaxLedgerMessageLength && IsPrintableAscii(message)) + format = OffchainMessageFormat.RestrictedAscii; + else if (message.Length <= MaxLedgerMessageLength && IsUtf8(message)) + format = OffchainMessageFormat.LimitedUtf8; + else if (IsUtf8(message)) + format = OffchainMessageFormat.ExtendedUtf8; + else + throw new ArgumentException("An off-chain message must contain valid UTF-8.", nameof(message)); + + return new OffchainMessage(format, message); + } + + /// Creates a version-0 off-chain message from valid .NET text. + /// A non-empty string whose strict UTF-8 representation is within the wire limit. + /// The validated off-chain message. + /// is null. + /// is empty or contains invalid Unicode. + /// The UTF-8 representation exceeds bytes. + public static OffchainMessage Create(string message) + { + ArgumentNullException.ThrowIfNull(message); + + try + { + return Create(StrictUtf8.GetBytes(message)); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException("An off-chain message must contain valid Unicode text.", nameof(message), exception); + } + } + + /// Returns a defensive copy of the payload bytes, without the wire header. + /// A new byte array containing the message payload. + public byte[] ToMessageBytes() => [.. _message]; + + /// Returns the exact domain-separated bytes that are hashed and signed. + /// The complete serialized message. + public byte[] Serialize() + { + var bytes = new byte[HeaderLength + VersionHeaderLength + _message.Length]; + SigningDomain.CopyTo(bytes, 0); + bytes[SigningDomain.Length] = CurrentVersion; + bytes[HeaderLength] = (byte)Format; + bytes[HeaderLength + 1] = (byte)_message.Length; + bytes[HeaderLength + 2] = (byte)(_message.Length >> 8); + _message.CopyTo(bytes, HeaderLength + VersionHeaderLength); + return bytes; + } + + /// Parses and validates a complete domain-separated off-chain message. + /// The complete serialized bytes. + /// The decoded version-0 message. + /// + /// has a wrong domain, version, length, format, or payload encoding. + /// + public static OffchainMessage Deserialize(ReadOnlySpan data) + { + if (data.Length is < HeaderLength + VersionHeaderLength + 1 or > ushort.MaxValue) + throw new FormatException("Off-chain message length is outside the version-0 wire range."); + if (!data[..SigningDomain.Length].SequenceEqual(SigningDomain)) + throw new FormatException("Off-chain message signing domain is invalid."); + if (data[SigningDomain.Length] != CurrentVersion) + throw new FormatException($"Unsupported off-chain message version {data[SigningDomain.Length]}."); + + var formatByte = data[HeaderLength]; + if (formatByte > (byte)OffchainMessageFormat.ExtendedUtf8) + throw new FormatException($"Unsupported off-chain message format {formatByte}."); + + var messageLength = data[HeaderLength + 1] | (data[HeaderLength + 2] << 8); + if (messageLength == 0 || HeaderLength + VersionHeaderLength + messageLength != data.Length) + throw new FormatException("Off-chain message payload length does not match its header."); + + var format = (OffchainMessageFormat)formatByte; + var message = data[(HeaderLength + VersionHeaderLength)..]; + var valid = format switch + { + OffchainMessageFormat.RestrictedAscii => message.Length <= MaxLedgerMessageLength && IsPrintableAscii(message), + OffchainMessageFormat.LimitedUtf8 => message.Length <= MaxLedgerMessageLength && IsUtf8(message), + OffchainMessageFormat.ExtendedUtf8 => message.Length <= MaxMessageLength && IsUtf8(message), + _ => false + }; + if (!valid) + throw new FormatException("Off-chain message payload does not satisfy its declared format."); + + return new OffchainMessage(format, message); + } + + /// Computes the SHA-256 hash of the exact serialized message. + /// The typed Solana hash. + public Hash ComputeHash() => new(SHA256.HashData(Serialize())); + + /// Signs the exact serialized message with a Solana signer. + /// The signer whose key will authenticate the message. + /// The typed Ed25519 signature. + /// is null. + /// The signer returns a value other than 64 bytes. + public Signature Sign(ISigner signer) + { + ArgumentNullException.ThrowIfNull(signer); + return new Signature(signer.Sign(Serialize())); + } + + /// Verifies a signature over the exact serialized message. + /// The public key expected to have signed the message. + /// The signature to verify. + /// true if the signature is valid under Solana's strict Ed25519 rules. + public bool Verify(PublicKey signer, Signature signature) => signature.Verify(signer, Serialize()); + + /// Determines whether this message equals . + /// The message to compare with. + /// true if version, format, and payload bytes are equal. + public bool Equals(OffchainMessage? other) + => other is not null && Format == other.Format && _message.AsSpan().SequenceEqual(other._message); + + /// + public override bool Equals(object? obj) => obj is OffchainMessage other && Equals(other); + + /// + public override int GetHashCode() + { + var hash = default(HashCode); + hash.Add(Format); + foreach (var value in _message) + hash.Add(value); + return hash.ToHashCode(); + } + + private static bool IsPrintableAscii(ReadOnlySpan message) + { + foreach (var value in message) + { + if (value is < 0x20 or > 0x7E) + return false; + } + + return true; + } + + private static bool IsUtf8(ReadOnlySpan message) + { + try + { + _ = StrictUtf8.GetCharCount(message); + return true; + } + catch (DecoderFallbackException) + { + return false; + } + } +} diff --git a/src/SolSharp.Wallet/Presigner.cs b/src/SolSharp.Wallet/Presigner.cs new file mode 100644 index 0000000..4d867b4 --- /dev/null +++ b/src/SolSharp.Wallet/Presigner.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using SolSharp.Core.Primitives; + +namespace SolSharp.Wallet; + +/// +/// An backed by a signature produced outside this process. Before returning +/// the signature, it verifies that the signature belongs to and covers the +/// exact requested message. This matches the Solana SDK presigner contract and prevents a signature +/// collected for one transaction from being attached to another. +/// +/// The key that produced . +/// The externally produced signature. +public sealed class Presigner(PublicKey publicKey, Signature signature) : ISigner +{ + /// + public PublicKey PublicKey { get; } = publicKey; + + /// The externally produced signature. + public Signature Signature { get; } = signature; + + /// + /// Verifies and returns the externally produced signature for . + /// + /// The exact message bytes that must have been signed. + /// A new array containing the 64-byte signature. + /// + /// The signature is not valid for and . + /// + public byte[] Sign(ReadOnlySpan message) + { + if (!Signature.Verify(PublicKey, message)) + throw new CryptographicException("The external signature does not verify for this public key and message."); + + return Signature.ToBytes(); + } +} diff --git a/src/SolSharp.Wallet/PublicKeyExtensions.cs b/src/SolSharp.Wallet/PublicKeyExtensions.cs index ffe07ef..f260f23 100644 --- a/src/SolSharp.Wallet/PublicKeyExtensions.cs +++ b/src/SolSharp.Wallet/PublicKeyExtensions.cs @@ -9,20 +9,47 @@ namespace SolSharp.Wallet; /// public static class PublicKeyExtensions { - /// Verifies an Ed25519 signature of against this public key. + /// + /// Verifies a typed Ed25519 using Solana-compatible strict validation. + /// + /// The public key the signature must verify under. + /// The signed message bytes. + /// The signature to check. + /// + /// true if is valid for and + /// under Solana's strict Ed25519 rules; false otherwise. + /// + public static bool Verify(this PublicKey key, ReadOnlySpan message, Signature signature) + { + Span bytes = stackalloc byte[Signature.Length]; + signature.CopyTo(bytes); + return key.Verify(message, bytes); + } + + /// + /// Verifies an Ed25519 signature of against this public key using + /// Solana-compatible strict validation, including rejection of small-order public-key and + /// signature R points. + /// /// The public key the signature must verify under. /// The signed message bytes. /// The 64-byte Ed25519 signature to check. /// /// true if is a valid Ed25519 signature of /// by ; false otherwise, including when is not - /// 64 bytes long. + /// 64 bytes long or contains a small-order point. /// public static bool Verify(this PublicKey key, ReadOnlySpan message, ReadOnlySpan signature) { if (signature.Length != Ed25519.SignatureSize) return false; + // Bouncy Castle's verifier rejects small-order public keys but accepts a small-order R. + // Solana's strict verifier rejects both; Partial rejects torsion-only points without the + // prime-subgroup requirement of ValidatePublicKeyFull, so mixed-torsion points remain valid. + if (!Ed25519.ValidatePublicKeyPartial(signature[..PublicKey.Length])) + return false; + // The span overload verifies without copying the signature, key, or message - allocation-free. Span keyBytes = stackalloc byte[PublicKey.Length]; key.CopyTo(keyBytes); diff --git a/src/SolSharp.Wallet/Signature.cs b/src/SolSharp.Wallet/Signature.cs new file mode 100644 index 0000000..13f045e --- /dev/null +++ b/src/SolSharp.Wallet/Signature.cs @@ -0,0 +1,176 @@ +using System.Buffers.Binary; +using SolSharp.Core.Encoding; +using SolSharp.Core.Primitives; + +namespace SolSharp.Wallet; + +/// +/// A 64-byte Ed25519 signature with Solana base58 parsing, formatting, and value equality. +/// Signature verification uses the strict Solana-compatible rules implemented by Wallet. +/// +public readonly struct Signature : IEquatable +{ + /// The length of an Ed25519 signature in bytes (64). + public const int Length = 64; + + private readonly ulong _a; + private readonly ulong _b; + private readonly ulong _c; + private readonly ulong _d; + private readonly ulong _e; + private readonly ulong _f; + private readonly ulong _g; + private readonly ulong _h; + private readonly string? _base58; + + /// Creates a signature value from its 64 raw bytes. + /// Exactly bytes. + /// is not bytes long. + public Signature(ReadOnlySpan bytes) : this(bytes, null) + { + } + + /// Creates a signature value from its base58 string form. + /// The base58-encoded signature; must decode to exactly bytes. + /// is not valid base58 or does not decode to bytes. + public Signature(string base58) : this(Decode(base58), base58) + { + } + + private Signature(ReadOnlySpan bytes, string? base58) + { + if (bytes.Length != Length) + throw new ArgumentException($"Signature must be {Length} bytes, got {bytes.Length}.", nameof(bytes)); + + _a = BinaryPrimitives.ReadUInt64LittleEndian(bytes); + _b = BinaryPrimitives.ReadUInt64LittleEndian(bytes[8..]); + _c = BinaryPrimitives.ReadUInt64LittleEndian(bytes[16..]); + _d = BinaryPrimitives.ReadUInt64LittleEndian(bytes[24..]); + _e = BinaryPrimitives.ReadUInt64LittleEndian(bytes[32..]); + _f = BinaryPrimitives.ReadUInt64LittleEndian(bytes[40..]); + _g = BinaryPrimitives.ReadUInt64LittleEndian(bytes[48..]); + _h = BinaryPrimitives.ReadUInt64LittleEndian(bytes[56..]); + _base58 = base58; + } + + /// Parses a signature from its base58 string form. + /// The base58-encoded signature; must decode to exactly bytes. + /// The parsed signature. + /// is not valid base58 or does not decode to bytes. + public static Signature Parse(string base58) => new(base58); + + /// Tries to parse a signature from its base58 string form, without throwing. + /// The base58-encoded signature, or null. + /// The parsed signature on success; otherwise. + /// true if decoded to a valid -byte signature. + public static bool TryParse(string? base58, out Signature signature) + { + if (Base58.TryDecode(base58, out var bytes) && bytes.Length == Length) + { + signature = new Signature(bytes, base58); + return true; + } + + signature = default; + return false; + } + + /// Writes the 64 raw bytes into . + /// The span to write into; must be at least bytes. + /// is smaller than bytes. + public void CopyTo(Span destination) + { + if (destination.Length < Length) + throw new ArgumentException($"Destination must be at least {Length} bytes.", nameof(destination)); + + BinaryPrimitives.WriteUInt64LittleEndian(destination, _a); + BinaryPrimitives.WriteUInt64LittleEndian(destination[8..], _b); + BinaryPrimitives.WriteUInt64LittleEndian(destination[16..], _c); + BinaryPrimitives.WriteUInt64LittleEndian(destination[24..], _d); + BinaryPrimitives.WriteUInt64LittleEndian(destination[32..], _e); + BinaryPrimitives.WriteUInt64LittleEndian(destination[40..], _f); + BinaryPrimitives.WriteUInt64LittleEndian(destination[48..], _g); + BinaryPrimitives.WriteUInt64LittleEndian(destination[56..], _h); + } + + /// Returns the 64 raw bytes of the signature as a new array. + /// A new -byte array. + public byte[] ToBytes() + { + var bytes = new byte[Length]; + CopyTo(bytes); + return bytes; + } + + /// Verifies this signature against the exact signed message and public key. + /// The signer's Ed25519 public key. + /// The exact message bytes that were signed. + /// + /// true when this is a valid strict Ed25519 signature of by + /// ; false otherwise. + /// + public bool Verify(PublicKey publicKey, ReadOnlySpan message) => publicKey.Verify(message, this); + + /// Determines whether this signature equals . + /// The signature to compare with. + /// true if both values hold the same 64 bytes. + public bool Equals(Signature other) + => _a == other._a + && _b == other._b + && _c == other._c + && _d == other._d + && _e == other._e + && _f == other._f + && _g == other._g + && _h == other._h; + + /// + public override bool Equals(object? obj) => obj is Signature other && Equals(other); + + /// + public override int GetHashCode() + { + var hash = default(HashCode); + hash.Add(_a); + hash.Add(_b); + hash.Add(_c); + hash.Add(_d); + hash.Add(_e); + hash.Add(_f); + hash.Add(_g); + hash.Add(_h); + return hash.ToHashCode(); + } + + /// Returns the Solana base58 string form of the signature. + /// The base58-encoded signature. + public override string ToString() + { + if (_base58 is not null) + return _base58; + + Span bytes = stackalloc byte[Length]; + CopyTo(bytes); + return Base58.Encode(bytes); + } + + /// Determines whether two signatures hold the same bytes. + /// The left signature. + /// The right signature. + /// true if the signatures are equal. + public static bool operator ==(Signature left, Signature right) => left.Equals(right); + + /// Determines whether two signatures hold different bytes. + /// The left signature. + /// The right signature. + /// true if the signatures are not equal. + public static bool operator !=(Signature left, Signature right) => !left.Equals(right); + + private static byte[] Decode(string base58) + { + if (!Base58.TryDecode(base58, out var bytes)) + throw new ArgumentException($"Not a valid base58 string: '{base58}'.", nameof(base58)); + + return bytes; + } +} diff --git a/src/SolSharp.Wallet/Slip10.cs b/src/SolSharp.Wallet/Slip10.cs index 69d6e70..1719102 100644 --- a/src/SolSharp.Wallet/Slip10.cs +++ b/src/SolSharp.Wallet/Slip10.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using System.Globalization; using System.Security.Cryptography; namespace SolSharp.Wallet; @@ -70,7 +71,8 @@ private static uint[] ParsePath(string path) throw new FormatException( $"Ed25519 (SLIP-0010) supports hardened derivation only; write \"{part}'\" instead of \"{part}\"."); - if (!uint.TryParse(part[..^1], out var index) || index >= HardenedOffset) + if (!uint.TryParse(part.AsSpan(0, part.Length - 1), NumberStyles.None, CultureInfo.InvariantCulture, out var index) + || index >= HardenedOffset) throw new FormatException($"Invalid derivation index '{part}'."); indexes[n - 1] = index | HardenedOffset; diff --git a/src/SolSharp.Wallet/SolSharp.Wallet.csproj b/src/SolSharp.Wallet/SolSharp.Wallet.csproj index ccc7af2..b7c4af6 100644 --- a/src/SolSharp.Wallet/SolSharp.Wallet.csproj +++ b/src/SolSharp.Wallet/SolSharp.Wallet.csproj @@ -3,11 +3,12 @@ net8.0 true - Ed25519 keys and signing for Solana on .NET: keypair generation, key parsing, message signing and verification, built on a vetted Ed25519 implementation. + Strict Ed25519 and BLS12-381 keys/signatures for Solana on .NET: secure import/export, local/external/offline signers, signed off-chain messages, BIP-39/SLIP-0010 derivation, Vote proofs of possession, and PoP-gated same-message aggregation over vetted crypto backends. - + + diff --git a/src/SolSharp.Wallet/WalletJsonContext.cs b/src/SolSharp.Wallet/WalletJsonContext.cs index dd9222a..878db57 100644 --- a/src/SolSharp.Wallet/WalletJsonContext.cs +++ b/src/SolSharp.Wallet/WalletJsonContext.cs @@ -3,8 +3,8 @@ namespace SolSharp.Wallet; /// -/// Source-generated metadata for 's key-file format, keeping the -/// parse reflection-free (Native AOT safe). +/// Source-generated metadata for Ed25519 and BLS keypair JSON import and export, +/// keeping key-file processing reflection-free (Native AOT safe). /// [JsonSerializable(typeof(int[]))] internal sealed partial class WalletJsonContext : JsonSerializerContext; diff --git a/src/SolSharp/CompatibilitySuppressions.xml b/src/SolSharp/CompatibilitySuppressions.xml new file mode 100644 index 0000000..d435dff --- /dev/null +++ b/src/SolSharp/CompatibilitySuppressions.xml @@ -0,0 +1,144 @@ + + + + + CP0002 + M:SolSharp.Rpc.DataSlice.#ctor(System.Int32,System.Int32) + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.DataSlice.Deconstruct(System.Int32@,System.Int32@) + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.DataSlice.get_Length + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.DataSlice.get_Offset + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.BlockProduction.get_ByIdentity + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.ClusterNode.get_FeatureSet + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.ClusterNode.get_ShredVersion + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.InnerInstruction.get_Accounts + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.InnerInstruction.get_ProgramIdIndex + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.InnerInstruction.get_StackHeight + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.InnerInstructionGroup.get_Index + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.Parsed.ParsedInnerInstructions.get_Index + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.Parsed.ParsedInstruction.get_StackHeight + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.RpcVersion.get_FeatureSet + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.TokenAmount.get_Decimals + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.TokenAmount.get_UiAmount + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.TokenBalance.get_AccountIndex + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Models.VoteAccount.get_EpochCredits + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.SolanaRpcClient.GetLeaderScheduleAsync(System.Nullable{System.UInt64},SolSharp.Core.Primitives.Commitment,System.Threading.CancellationToken) + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + + CP0002 + M:SolSharp.Rpc.Streaming.SlotsUpdate.get_Timestamp + lib/net8.0/SolSharp.Rpc.dll + lib/net8.0/SolSharp.Rpc.dll + true + + \ No newline at end of file diff --git a/src/SolSharp/SolSharp.csproj b/src/SolSharp/SolSharp.csproj index 5328304..2c6ab46 100644 --- a/src/SolSharp/SolSharp.csproj +++ b/src/SolSharp/SolSharp.csproj @@ -2,10 +2,13 @@ net8.0 + true SolSharp true - A lean, Native AOT-ready .NET 8 SDK for Solana — source-generated JSON, no reflection, trimmable, compiles to a native binary. The full JSON-RPC HTTP read surface, send/simulate, and multiplexed WebSocket streaming; Ed25519 keys and signing; SPL Token, PDA, and ATA helpers; and spec-accurate legacy and v0 (versioned) transaction building, signing, and decoding — every wire format checked byte-for-byte against the Rust solana-sdk. Ships as one package bundling the Core, Wallet, Rpc, and Programs assemblies. - 1.3.0 — hardens the transport and the send path. WebSocket: bounded message sizes (MaxMessageSizeBytes, 64 MiB default) and per-subscription buffers (SubscriptionBufferCapacity, 1,024 default; a consumer that falls behind is faulted and unsubscribed instead of growing memory without bound), an opt-in ReceiveTimeout that recovers silently half-open connections, a complete close handshake with bounded disposal, and a fix so a subscription cancelled during a reconnect replay is released server-side instead of resurrected. HTTP: the JSON-RPC response envelope is validated in a single parsing pass (version, id echo, result/error presence) and the node's own error always surfaces with its code and message; sendTransaction preflight and simulateTransaction default to confirmed commitment, matching GetLatestBlockhashAsync, so a just-fetched blockhash no longer fails preflight with BlockhashNotFound; malformed base64 transaction tuples are rejected instead of silently mis-decoded. Message.Deserialize and MessageV0.Deserialize enforce Solana's sanitize rules. Packaging: the bundled assemblies' XML documentation now ships in the package (IntelliSense), and Microsoft.Extensions.Http.Resilience 8.10.0 drops the transitive vulnerable System.Text.Json 8.0.0. No breaking API changes. Changelog: https://github.com/jecacs/SolSharp/blob/main/CHANGELOG.md + true + 1.3.0 + A contract-driven, Native AOT-ready .NET 8 SDK for Solana — independently implemented from pinned Anza Solana SDK, Agave, and SPL source contracts. Source-generated JSON with no reflection; typed RPC and multiplexed WebSocket streaming; Ed25519 and BLS12-381 keys/signatures; program instructions and bounded state decoding; and exact legacy/v0/V1 transaction wire formats verified against Rust-compatible vectors. Ships as one package bundling the Core, Wallet, Rpc, and Programs functional assemblies plus a minimal packaging facade. + 2.0.0 — the contract-driven client-parity release, independently implemented against pinned Anza Solana SDK, Agave, and SPL sources. It adds exact legacy/v0/SIMD-0385 V1 transaction workflows; typed Ed25519/BLS12-381 values, offline signers, Rust-compatible key import/export, signed off-chain messages, Vote proofs of possession, and PoP-gated same-message BLS aggregation; broad native-program and Token-2022 interfaces with bounded state decoders; the complete pinned non-admin RPC/PubSub method-family surface and effective configuration variants; plus transport, malformed-input, Native AOT, package-validation, and release hardening. Migration: untyped null/default recent-blockhash or durable-nonce arguments need a string/Hash cast; DataSlice now uses ulong; and exact RPC response fields use typed unsigned/union models instead of permissive signed/JsonElement containers. These are deliberate 2.0 source and binary changes, so applications built against 1.x must be recompiled. Exact source revisions, supported contracts, exclusions, native RID requirements, and byte-vector policy are published in the parity matrix and third-party notices. Full details: https://github.com/jecacs/SolSharp/blob/v2.0.0/CHANGELOG.md $(TargetsForTfmSpecificBuildOutput);BundleProjectReferences @@ -17,11 +20,12 @@ - - + + + - + diff --git a/tests/SolSharp.Core.Tests/Constants/SolanaProgramIdsTests.cs b/tests/SolSharp.Core.Tests/Constants/SolanaProgramIdsTests.cs index 20d15d9..e3d005c 100644 --- a/tests/SolSharp.Core.Tests/Constants/SolanaProgramIdsTests.cs +++ b/tests/SolSharp.Core.Tests/Constants/SolanaProgramIdsTests.cs @@ -11,7 +11,7 @@ public static class SolanaProgramIdsTests // Reflect over every constant so new additions are guarded automatically. public static IEnumerable AllConstants() { - foreach (var type in new[] { typeof(SolanaProgramIds), typeof(Sysvars), typeof(Mints) }) + foreach (var type in new[] { typeof(SolanaProgramIds), typeof(SolanaFeatureIds), typeof(Sysvars), typeof(Mints) }) { foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Static)) { diff --git a/tests/SolSharp.Core.Tests/Converters/SolanaJsonSerializerTests.cs b/tests/SolSharp.Core.Tests/Converters/SolanaJsonSerializerTests.cs index 625d388..e6aeff2 100644 --- a/tests/SolSharp.Core.Tests/Converters/SolanaJsonSerializerTests.cs +++ b/tests/SolSharp.Core.Tests/Converters/SolanaJsonSerializerTests.cs @@ -14,10 +14,7 @@ public sealed class Options private const string SystemProgram = "11111111111111111111111111111111"; [Test] - public void IsFrozen() - { - SolanaJsonSerializer.Options.IsReadOnly.Should().BeTrue(); - } + public void IsFrozen() => SolanaJsonSerializer.Options.IsReadOnly.Should().BeTrue(); [Test] public void SerializesTheCoreWirePrimitives() @@ -25,6 +22,8 @@ public void SerializesTheCoreWirePrimitives() // Act & Assert JsonSerializer.Serialize(Commitment.Confirmed, SolanaJsonSerializer.Options) .Should().Be("\"confirmed\""); + JsonSerializer.Serialize(Hash.Parse(SystemProgram), SolanaJsonSerializer.Options) + .Should().Be($"\"{SystemProgram}\""); JsonSerializer.Serialize(PublicKey.Parse(SystemProgram), SolanaJsonSerializer.Options) .Should().Be($"\"{SystemProgram}\""); } @@ -35,10 +34,35 @@ public void DeserializesTheCoreWirePrimitives() // Act & Assert JsonSerializer.Deserialize("\"finalized\"", SolanaJsonSerializer.Options) .Should().Be(Commitment.Finalized); + JsonSerializer.Deserialize($"\"{SystemProgram}\"", SolanaJsonSerializer.Options) + .Should().Be(Hash.Parse(SystemProgram)); JsonSerializer.Deserialize($"\"{SystemProgram}\"", SolanaJsonSerializer.Options) .Should().Be(PublicKey.Parse(SystemProgram)); } + [Test] + public void SerializesNullablePublicKey() + { + // Arrange + PublicKey? key = PublicKey.Parse(SystemProgram); + + // Act & Assert + JsonSerializer.Serialize(key, SolanaJsonSerializer.Options) + .Should().Be($"\"{SystemProgram}\""); + JsonSerializer.Serialize(null, SolanaJsonSerializer.Options) + .Should().Be("null"); + } + + [Test] + public void DeserializesNullablePublicKey() + { + // Act & Assert + JsonSerializer.Deserialize($"\"{SystemProgram}\"", SolanaJsonSerializer.Options) + .Should().Be(PublicKey.Parse(SystemProgram)); + JsonSerializer.Deserialize("null", SolanaJsonSerializer.Options) + .Should().BeNull(); + } + [Test] public void Serialize_UnregisteredType_ThrowsInsteadOfFallingBackToReflection() { diff --git a/tests/SolSharp.Core.Tests/Encoding/BorshReaderTests.cs b/tests/SolSharp.Core.Tests/Encoding/BorshReaderTests.cs index 5105972..369e03b 100644 --- a/tests/SolSharp.Core.Tests/Encoding/BorshReaderTests.cs +++ b/tests/SolSharp.Core.Tests/Encoding/BorshReaderTests.cs @@ -22,13 +22,13 @@ public void ReadsBorshPrimitivesInOrder() { // Arrange var data = Convert.FromHexString( - "2a" + // u8 = 42 - "78563412" + // u32 = 0x12345678 - "40420f0000000000" + // u64 = 1_000_000 - "01" + // bool = true + "2a" + // u8 = 42 + "78563412" + // u32 = 0x12345678 + "40420f0000000000" + // u64 = 1_000_000 + "01" + // bool = true "01" + "0700000000000000" + // Option Some, u64 = 7 - "00" + // Option None - "02000000" + "6869" + // string "hi" (length 2, then "hi") + "00" + // Option None + "02000000" + "6869" + // string "hi" (length 2, then "hi") "0909090909090909090909090909090909090909090909090909090909090909" + // pubkey [9]*32 "03000000" + "010203"); // Vec length 3, then 1, 2, 3 @@ -64,7 +64,7 @@ public sealed class Bounds public void ReadingPastEnd_Throws() { // Act - Action act = () => ReadU64From([1, 2]); + var act = () => ReadU64From([1, 2]); // Assert act.Should().Throw(); @@ -72,4 +72,93 @@ public void ReadingPastEnd_Throws() private static void ReadU64From(byte[] data) => new BorshReader(data).ReadU64(); } + + [TestFixture] + public sealed class ReadBool + { + [Test] + public void Zero_ReturnsFalse() + { + // Arrange + var reader = new BorshReader([0]); + + // Act + var value = reader.ReadBool(); + + // Assert + value.Should().BeFalse(); + } + + [TestCase((byte)0x02)] + [TestCase((byte)0xff)] + public void NonCanonicalDiscriminant_ThrowsFormatException(byte value) + { + // Arrange + var data = new[] { value }; + + // Act + var act = () => ReadBoolFrom(data); + + // Assert + act.Should().Throw().WithMessage("*must be 0 or 1*"); + } + + private static void ReadBoolFrom(byte[] data) => new BorshReader(data).ReadBool(); + } + + [TestFixture] + public sealed class ReadOption + { + [TestCase((byte)0x02)] + [TestCase((byte)0xff)] + public void NonCanonicalDiscriminant_ThrowsFormatException(byte value) + { + // Arrange + var data = new[] { value }; + + // Act + var act = () => ReadOptionFrom(data); + + // Assert + act.Should().Throw().WithMessage("*must be 0 or 1*"); + } + + private static void ReadOptionFrom(byte[] data) => new BorshReader(data).ReadOption(); + } + + [TestFixture] + public sealed class ReadString + { + [Test] + public void MultibyteScalars_DecodesUtf8Vector() + { + // Arrange + var data = Convert.FromHexString("0a00000041c2a2e282acf0908d88"); + var reader = new BorshReader(data); + + // Act + var value = reader.ReadString(); + + // Assert + value.Should().Be("A¢€𐍈"); + reader.Remaining.Should().Be(0); + } + + [Test] + public void InvalidUtf8_ThrowsFormatException() + { + // Arrange + var data = Convert.FromHexString("01000000ff"); + + // Act + var act = () => ReadStringFrom(data); + + // Assert + act.Should().Throw() + .WithMessage("*invalid UTF-8*") + .WithInnerException(); + } + + private static void ReadStringFrom(byte[] data) => new BorshReader(data).ReadString(); + } } diff --git a/tests/SolSharp.Core.Tests/Encoding/BorshWriterTests.cs b/tests/SolSharp.Core.Tests/Encoding/BorshWriterTests.cs index 0b35963..7e6b138 100644 --- a/tests/SolSharp.Core.Tests/Encoding/BorshWriterTests.cs +++ b/tests/SolSharp.Core.Tests/Encoding/BorshWriterTests.cs @@ -7,6 +7,22 @@ namespace SolSharp.Core.Tests.Encoding; public static class BorshWriterTests { + [TestFixture] + public sealed class Constructor + { + [TestCase(0)] + [TestCase(-1)] + public void NonPositiveCapacity_ThrowsDocumentedArgumentException(int initialCapacity) + { + // Act + Action act = () => _ = new BorshWriter(initialCapacity); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be(nameof(initialCapacity)); + } + } + [TestFixture] public sealed class Write { @@ -27,7 +43,7 @@ public void RoundTripsThroughBorshReader() writer.WriteU64(1_000_000); writer.WriteI64(-7); writer.WriteU128(UInt128.MaxValue); - writer.WriteI128((Int128)(-12345)); + writer.WriteI128(-12345); writer.WriteBool(true); writer.WriteOption(true); writer.WriteU64(7); @@ -50,15 +66,15 @@ public void RoundTripsThroughBorshReader() reader.ReadU64().Should().Be(1_000_000); reader.ReadI64().Should().Be(-7); reader.ReadU128().Should().Be(UInt128.MaxValue); - reader.ReadI128().Should().Be((Int128)(-12345)); + reader.ReadI128().Should().Be(-12345); reader.ReadBool().Should().BeTrue(); reader.ReadOption().Should().BeTrue(); reader.ReadU64().Should().Be(7); reader.ReadOption().Should().BeFalse(); reader.ReadString().Should().Be("hi"); reader.ReadPublicKey().Should().Be(pubkey); - reader.ReadBytes(2).ToArray().Should().Equal((byte)0xAA, 0xBB); - reader.ReadByteVector().Should().Equal((byte)1, 2, 3); + reader.ReadBytes(2).ToArray().Should().Equal(0xAA, 0xBB); + reader.ReadByteVector().Should().Equal(1, 2, 3); reader.Remaining.Should().Be(0); } @@ -97,10 +113,42 @@ public void WriteLength_NegativeThrows() var writer = new BorshWriter(); // Act - Action act = () => writer.WriteLength(-1); + var act = () => writer.WriteLength(-1); // Assert act.Should().Throw(); } } + + [TestFixture] + public sealed class WriteString + { + [Test] + public void MultibyteScalars_WritesUtf8Vector() + { + // Arrange + var writer = new BorshWriter(); + + // Act + writer.WriteString("A¢€𐍈"); + + // Assert + Convert.ToHexString(writer.ToArray()).ToLowerInvariant() + .Should().Be("0a00000041c2a2e282acf0908d88"); + } + + [Test] + public void LoneSurrogate_ThrowsEncoderFallbackExceptionWithoutWriting() + { + // Arrange + var writer = new BorshWriter(); + + // Act + var act = () => writer.WriteString("\ud800"); + + // Assert + act.Should().Throw(); + writer.Length.Should().Be(0); + } + } } diff --git a/tests/SolSharp.Core.Tests/Encoding/ShortVecTests.cs b/tests/SolSharp.Core.Tests/Encoding/ShortVecTests.cs index d247463..f1f0d21 100644 --- a/tests/SolSharp.Core.Tests/Encoding/ShortVecTests.cs +++ b/tests/SolSharp.Core.Tests/Encoding/ShortVecTests.cs @@ -21,7 +21,7 @@ public static IEnumerable Vectors() public static IEnumerable MalformedInputs() { - yield return new TestCaseData(new byte[] { }).SetName("Empty"); + yield return new TestCaseData(Array.Empty()).SetName("Empty"); yield return new TestCaseData(new byte[] { 0x80 }).SetName("Truncated"); yield return new TestCaseData(new byte[] { 0x80, 0x00 }).SetName("NonMinimal"); yield return new TestCaseData(new byte[] { 0x80, 0x80, 0x80 }).SetName("FourthByteContinuation"); @@ -37,20 +37,14 @@ public sealed class GetByteCount [TestCase(16383, 2)] [TestCase(16384, 3)] [TestCase(65535, 3)] - public void AtBoundaries_ReturnsExpected(int value, int expected) - { - ShortVec.GetByteCount(value).Should().Be(expected); - } + public void AtBoundaries_ReturnsExpected(int value, int expected) => ShortVec.GetByteCount(value).Should().Be(expected); } [TestFixture] public sealed class Encode { [TestCaseSource(typeof(ShortVecTests), nameof(Vectors))] - public void ReferenceVector_ProducesExpectedBytes(int value, byte[] expected) - { - ShortVec.Encode(value).Should().Equal(expected); - } + public void ReferenceVector_ProducesExpectedBytes(int value, byte[] expected) => ShortVec.Encode(value).Should().Equal(expected); [TestCase(-1)] [TestCase(65536)] diff --git a/tests/SolSharp.Core.Tests/Primitives/CommitmentTests.cs b/tests/SolSharp.Core.Tests/Primitives/CommitmentTests.cs index 6c5a169..a1779af 100644 --- a/tests/SolSharp.Core.Tests/Primitives/CommitmentTests.cs +++ b/tests/SolSharp.Core.Tests/Primitives/CommitmentTests.cs @@ -37,5 +37,18 @@ public void UnknownValue_Throws() // Assert act.Should().Throw(); } + + [TestCase("123")] + [TestCase("true")] + [TestCase("{}")] + [TestCase("[]")] + public void NonStringValue_ThrowsJsonException(string json) + { + // Act + Action act = () => JsonSerializer.Deserialize(json); + + // Assert + act.Should().Throw(); + } } } diff --git a/tests/SolSharp.Core.Tests/Primitives/HashTests.cs b/tests/SolSharp.Core.Tests/Primitives/HashTests.cs new file mode 100644 index 0000000..d300365 --- /dev/null +++ b/tests/SolSharp.Core.Tests/Primitives/HashTests.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Core.Tests.Primitives; + +public static class HashTests +{ + // solana-sdk/hash/src/lib.rs test vector: 32 bytes whose value is one. + private const string Sample = "4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi"; + + [TestFixture] + public sealed class Constructor + { + [Test] + public void UpstreamKnownVector_RoundTripsBytesAndBase58() + { + // Arrange + var bytes = Enumerable.Repeat((byte)1, Hash.Length).ToArray(); + + // Act + var hash = new Hash(bytes); + + // Assert + hash.ToBytes().Should().Equal(bytes); + hash.ToString().Should().Be(Sample); + } + + [TestCase(0)] + [TestCase(31)] + [TestCase(33)] + public void WrongLength_Throws(int length) + { + // Act + Action act = () => _ = new Hash(new byte[length]); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class Parse + { + [Test] + public void ValidBase58_RoundTripsToSameString() => Hash.Parse(Sample).ToString().Should().Be(Sample); + + [TestCase("0")] + [TestCase("abc")] + public void Invalid_Throws(string input) + { + // Act + Action act = () => Hash.Parse(input); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class TryParse + { + [Test] + public void ValidBase58_ReturnsTrueAndHash() + { + // Act + var parsed = Hash.TryParse(Sample, out var hash); + + // Assert + parsed.Should().BeTrue(); + hash.ToString().Should().Be(Sample); + } + + [TestCase("0")] + [TestCase("abc")] + [TestCase(null)] + [TestCase("")] + public void Invalid_ReturnsFalseAndDefault(string? input) + { + // Act + var parsed = Hash.TryParse(input, out var hash); + + // Assert + parsed.Should().BeFalse(); + hash.Should().Be(default(Hash)); + } + } + + [TestFixture] + public new sealed class Equals + { + [Test] + public void SameBytes_AreEqual() + { + // Arrange + var a = Hash.Parse(Sample); + var b = new Hash(a.ToBytes()); + + // Act & Assert + a.Should().Be(b); + } + + [Test] + public void DifferentBytes_AreNotEqual() + { + // Arrange + var a = Hash.Parse(Sample); + var b = default(Hash); + + // Act & Assert + a.Should().NotBe(b); + } + + [Test] + public void Default_EqualsAllZeroHash() + => default(Hash).Should().Be(new Hash(new byte[Hash.Length])); + } + + [TestFixture] + public sealed class EqualityOperators + { + [Test] + public void SameBytes_AreEqual() + { + // Arrange + var a = Hash.Parse(Sample); + var b = new Hash(a.ToBytes()); + + // Act & Assert + (a == b).Should().BeTrue(); + } + + [Test] + public void DifferentBytes_AreNotEqual() + { + // Arrange + var a = Hash.Parse(Sample); + var b = default(Hash); + + // Act & Assert + (a != b).Should().BeTrue(); + } + } + + [TestFixture] + public new sealed class GetHashCode + { + [Test] + public void SameBytes_HaveSameHashCode() + { + // Arrange + var a = Hash.Parse(Sample); + var b = new Hash(a.ToBytes()); + + // Act & Assert + a.GetHashCode().Should().Be(b.GetHashCode()); + } + } + + [TestFixture] + public sealed class CopyTo + { + [Test] + public void CopyTo_WritesAllBytes() + { + // Arrange + var hash = Hash.Parse(Sample); + var destination = new byte[Hash.Length]; + + // Act + hash.CopyTo(destination); + + // Assert + destination.Should().Equal(Enumerable.Repeat((byte)1, Hash.Length)); + } + + [Test] + public void CopyTo_DestinationTooSmall_Throws() + { + // Arrange + var hash = Hash.Parse(Sample); + + // Act + var act = () => hash.CopyTo(new byte[Hash.Length - 1]); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class Serialize + { + [Test] + public void Serializes_ToBase58String() + => JsonSerializer.Serialize(Hash.Parse(Sample)).Should().Be($"\"{Sample}\""); + } + + [TestFixture] + public sealed class Deserialize + { + [Test] + public void Deserializes_FromBase58String() + => JsonSerializer.Deserialize($"\"{Sample}\"").Should().Be(Hash.Parse(Sample)); + + [Test] + public void Deserialize_Invalid_Throws() + { + // Act + Action act = () => JsonSerializer.Deserialize("\"0\""); + + // Assert + act.Should().Throw(); + } + + [TestCase("123")] + [TestCase("true")] + [TestCase("{}")] + [TestCase("[]")] + public void Deserialize_NonString_ThrowsJsonException(string json) + { + // Act + Action act = () => JsonSerializer.Deserialize(json); + + // Assert + act.Should().Throw(); + } + } +} diff --git a/tests/SolSharp.Core.Tests/Primitives/PublicKeyTests.cs b/tests/SolSharp.Core.Tests/Primitives/PublicKeyTests.cs index 49557ce..70e63ba 100644 --- a/tests/SolSharp.Core.Tests/Primitives/PublicKeyTests.cs +++ b/tests/SolSharp.Core.Tests/Primitives/PublicKeyTests.cs @@ -46,7 +46,7 @@ public sealed class Parse [Test] public void SystemProgram_IsThirtyTwoZeroBytes() => PublicKey.Parse(SolanaProgramIds.SystemProgram).ToBytes().Should().Equal(new byte[32]); - [TestCase("0")] // not in the base58 alphabet + [TestCase("0")] // not in the base58 alphabet [TestCase("abc")] // valid alphabet, wrong length public void Invalid_Throws(string input) { @@ -137,7 +137,7 @@ public void CopyTo_DestinationTooSmall_Throws() var key = PublicKey.Parse(Sample); // Act - Action act = () => key.CopyTo(new byte[PublicKey.Length - 1]); + var act = () => key.CopyTo(new byte[PublicKey.Length - 1]); // Assert act.Should().Throw(); @@ -161,5 +161,18 @@ public void Deserialize_Invalid_Throws() Action act = () => JsonSerializer.Deserialize("\"0\""); act.Should().Throw(); } + + [TestCase("123")] + [TestCase("true")] + [TestCase("{}")] + [TestCase("[]")] + public void Deserialize_NonString_ThrowsJsonException(string json) + { + // Act + Action act = () => JsonSerializer.Deserialize(json); + + // Assert + act.Should().Throw(); + } } } diff --git a/tests/SolSharp.Core.Tests/SolanaUnitsTests.cs b/tests/SolSharp.Core.Tests/SolanaUnitsTests.cs index 12e991a..040f70b 100644 --- a/tests/SolSharp.Core.Tests/SolanaUnitsTests.cs +++ b/tests/SolSharp.Core.Tests/SolanaUnitsTests.cs @@ -48,11 +48,9 @@ public void FarPastDecimalMultiplyRange_StillThrowsArgumentOutOfRange() } [Test] - public void ExactUlongBoundary_Converts() - { + public void ExactUlongBoundary_Converts() => // 18_446_744_073.709551615 SOL is exactly ulong.MaxValue lamports. SolanaUnits.SolToLamports(18_446_744_073.709551615m).Should().Be(ulong.MaxValue); - } } [TestFixture] diff --git a/tests/SolSharp.Core.Tests/SysvarStates/CollectionSysvarStateTests.cs b/tests/SolSharp.Core.Tests/SysvarStates/CollectionSysvarStateTests.cs new file mode 100644 index 0000000..2cfb26b --- /dev/null +++ b/tests/SolSharp.Core.Tests/SysvarStates/CollectionSysvarStateTests.cs @@ -0,0 +1,81 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.SysvarStates; + +namespace SolSharp.Core.Tests.SysvarStates; + +public static class SlotHashesSysvarStateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void PinnedBincodeVector_DecodesEntriesInWireOrder() + { + // Arrange + var data = Convert.FromHexString( + "02000000000000000900000000000000" + + "0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F20" + + "0800000000000000" + + "201F1E1D1C1B1A191817161514131211100F0E0D0C0B0A090807060504030201"); + + // Act + var state = SlotHashesSysvarState.Parse(data); + + // Assert + state.Entries.Select(entry => entry.Slot).Should().Equal(9, 8); + state.Entries[0].Hash.ToBytes().Should().Equal(Enumerable.Range(1, 32).Select(value => (byte)value)); + SlotHashesSysvarState.MaximumEntries.Should().Be(512); + SlotHashesSysvarState.MaximumDataLength.Should().Be(20_488); + } + + [Test] + public void CountAboveRuntimeMaximum_IsRejectedBeforeAllocation() + { + // Arrange + var data = Convert.FromHexString("0102000000000000"); + + // Act & Assert + FluentActions.Invoking(() => SlotHashesSysvarState.Parse(data)) + .Should().Throw().WithMessage("*exceeds the maximum*"); + } + } +} + +public static class StakeHistorySysvarStateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void PinnedBincodeVector_DecodesEpochAndTotals() + { + // Arrange + var data = Convert.FromHexString( + "0200000000000000" + + "09000000000000000A000000000000000B000000000000000C00000000000000" + + "08000000000000000D000000000000000E000000000000000F00000000000000"); + + // Act + var state = StakeHistorySysvarState.Parse(data); + + // Assert + state.Entries.Should().Equal( + new StakeHistoryEpoch(9, new StakeHistoryEntry(10, 11, 12)), + new StakeHistoryEpoch(8, new StakeHistoryEntry(13, 14, 15))); + StakeHistorySysvarState.MaximumEntries.Should().Be(512); + StakeHistorySysvarState.MaximumDataLength.Should().Be(16_392); + } + + [Test] + public void TruncatedEntry_IsRejected() + { + // Arrange + var data = Convert.FromHexString("01000000000000000100000000000000"); + + // Act & Assert + FluentActions.Invoking(() => StakeHistorySysvarState.Parse(data)) + .Should().Throw(); + } + } +} diff --git a/tests/SolSharp.Core.Tests/SysvarStates/FixedSysvarStateTests.cs b/tests/SolSharp.Core.Tests/SysvarStates/FixedSysvarStateTests.cs new file mode 100644 index 0000000..9589e55 --- /dev/null +++ b/tests/SolSharp.Core.Tests/SysvarStates/FixedSysvarStateTests.cs @@ -0,0 +1,141 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.SysvarStates; + +namespace SolSharp.Core.Tests.SysvarStates; + +public static class ClockSysvarStateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void PinnedBincodeVector_DecodesEveryField() + { + // Arrange + var data = Convert.FromHexString( + "0100000000000000FEFFFFFFFFFFFFFF03000000000000000400000000000000FBFFFFFFFFFFFFFF"); + + // Act + var state = ClockSysvarState.Parse(data); + + // Assert + state.Should().Be(new ClockSysvarState(1, -2, 3, 4, -5)); + ClockSysvarState.DataLength.Should().Be(40); + } + } +} + +public static class RentSysvarStateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void CurrentPinnedDefaultVector_DecodesRetainedWireFields() + { + // Arrange - Rent::default() at solana-rent 4.1 keeps threshold 1.0 and burn percent 50. + var data = Convert.FromHexString("301B000000000000000000000000F03F32"); + + // Act + var state = RentSysvarState.Parse(data); + + // Assert + state.Should().Be(new RentSysvarState(6_960, 1.0, 50)); + RentSysvarState.DataLength.Should().Be(17); + } + } +} + +public static class EpochScheduleSysvarStateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void PinnedBincodeVector_DecodesExactFieldOrder() + { + // Arrange + var data = Convert.FromHexString( + "010000000000000002000000000000000103000000000000000400000000000000"); + + // Act + var state = EpochScheduleSysvarState.Parse(data); + + // Assert + state.Should().Be(new EpochScheduleSysvarState(1, 2, true, 3, 4)); + EpochScheduleSysvarState.DataLength.Should().Be(33); + } + + [Test] + public void NonCanonicalBool_IsRejected() + { + // Arrange + var data = new byte[EpochScheduleSysvarState.DataLength]; + data[16] = 2; + + // Act & Assert + FluentActions.Invoking(() => EpochScheduleSysvarState.Parse(data)) + .Should().Throw(); + } + } +} + +public static class EpochRewardsSysvarStateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void PinnedBincodeVector_DecodesHashUInt128AndTail() + { + // Arrange + var data = Convert.FromHexString( + "01000000000000000200000000000000" + + "0303030303030303030303030303030303030303030303030303030303030303" + + "04000000000000000500000000000000" + + "0600000000000000070000000000000001"); + + // Act + var state = EpochRewardsSysvarState.Parse(data); + + // Assert + state.DistributionStartingBlockHeight.Should().Be(1); + state.NumberOfPartitions.Should().Be(2); + state.ParentBlockhash.ToBytes().Should().OnlyContain(value => value == 3); + state.TotalPoints.Should().Be(((UInt128)5 << 64) | 4); + state.TotalRewards.Should().Be(6); + state.DistributedRewards.Should().Be(7); + state.Active.Should().BeTrue(); + EpochRewardsSysvarState.DataLength.Should().Be(81); + } + } +} + +public static class LastRestartSlotSysvarStateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void PinnedBincodeVector_DecodesSlot() + { + // Arrange + var data = Convert.FromHexString("0807060504030201"); + + // Act + var state = LastRestartSlotSysvarState.Parse(data); + + // Assert + state.LastRestartSlot.Should().Be(0x0102030405060708UL); + LastRestartSlotSysvarState.DataLength.Should().Be(8); + } + + [TestCase("08070605040302")] + [TestCase("080706050403020100")] + public void NonExactLength_IsRejected(string hex) => + // Act & Assert + FluentActions.Invoking(() => LastRestartSlotSysvarState.Parse(Convert.FromHexString(hex))) + .Should().Throw(); + } +} diff --git a/tests/SolSharp.Core.Tests/SysvarStates/SlotHistorySysvarStateTests.cs b/tests/SolSharp.Core.Tests/SysvarStates/SlotHistorySysvarStateTests.cs new file mode 100644 index 0000000..18da2fb --- /dev/null +++ b/tests/SolSharp.Core.Tests/SysvarStates/SlotHistorySysvarStateTests.cs @@ -0,0 +1,98 @@ +using System.Buffers.Binary; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.SysvarStates; + +namespace SolSharp.Core.Tests.SysvarStates; + +public static class SlotHistorySysvarStateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void PinnedDefaultShapeVector_DecodesWincodeBitVectorAndNextSlot() + { + // Arrange + var data = BuildVector(firstBlock: 5, nextSlot: 21); + + // Act + var state = SlotHistorySysvarState.Parse(data); + + // Assert + state.NextSlot.Should().Be(21); + state.OldestSlot.Should().Be(0); + state.NewestSlot.Should().Be(20); + SlotHistorySysvarState.MaximumEntries.Should().Be(1_048_576); + SlotHistorySysvarState.BlockCount.Should().Be(16_384); + SlotHistorySysvarState.DataLength.Should().Be(131_097); + } + + [Test] + public void NonCanonicalBitVectorMetadata_IsRejected() + { + // Arrange + var optionNone = BuildVector(firstBlock: 1, nextSlot: 1); + optionNone[0] = 0; + var wrongBlockCount = BuildVector(firstBlock: 1, nextSlot: 1); + BinaryPrimitives.WriteUInt64LittleEndian(wrongBlockCount.AsSpan(1), 16_383); + var wrongBitLength = BuildVector(firstBlock: 1, nextSlot: 1); + BinaryPrimitives.WriteUInt64LittleEndian(wrongBitLength.AsSpan(131_081), 1_048_575); + + // Act & Assert + FluentActions.Invoking(() => SlotHistorySysvarState.Parse(optionNone)) + .Should().Throw(); + FluentActions.Invoking(() => SlotHistorySysvarState.Parse(wrongBlockCount)) + .Should().Throw(); + FluentActions.Invoking(() => SlotHistorySysvarState.Parse(wrongBitLength)) + .Should().Throw(); + } + + [TestCase(131_096)] + [TestCase(131_098)] + public void NonExactAccountLength_IsRejectedBeforeBlockAllocation(int length) + { + // Arrange + var data = new byte[length]; + + // Act & Assert + FluentActions.Invoking(() => SlotHistorySysvarState.Parse(data)) + .Should().Throw().WithMessage("*exactly 131097 bytes*"); + } + } + + [TestFixture] + public sealed class Check + { + [Test] + public void PinnedRuntimeOrdering_DistinguishesFoundMissingFutureAndTooOld() + { + // Arrange - bits 0 and 2 are set, and the retained range wraps so slots below 2 are too old. + var state = SlotHistorySysvarState.Parse( + BuildVector(firstBlock: 5, nextSlot: SlotHistorySysvarState.MaximumEntries + 2)); + + // Act + var tooOld = state.Check(1); + var found = state.Check(2); + var missing = state.Check(3); + var future = state.Check(SlotHistorySysvarState.MaximumEntries + 2); + + // Assert + tooOld.Should().Be(SlotHistoryCheck.TooOld); + found.Should().Be(SlotHistoryCheck.Found); + missing.Should().Be(SlotHistoryCheck.NotFound); + future.Should().Be(SlotHistoryCheck.Future); + } + } + + private static byte[] BuildVector(ulong firstBlock, ulong nextSlot) + { + var data = new byte[SlotHistorySysvarState.DataLength]; + data[0] = 1; + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(1), SlotHistorySysvarState.BlockCount); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(9), firstBlock); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(131_081), SlotHistorySysvarState.MaximumEntries); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(131_089), nextSlot); + return data; + } +} diff --git a/tests/SolSharp.Core.Tests/SysvarStates/SysvarAccountIdsTests.cs b/tests/SolSharp.Core.Tests/SysvarStates/SysvarAccountIdsTests.cs new file mode 100644 index 0000000..9d972ef --- /dev/null +++ b/tests/SolSharp.Core.Tests/SysvarStates/SysvarAccountIdsTests.cs @@ -0,0 +1,55 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Constants; + +namespace SolSharp.Core.Tests.SysvarStates; + +public static class SysvarsTests +{ + [TestFixture] + public sealed class Constants + { + [Test] + public void CurrentPinnedSdkExports_MatchCanonicalAddresses() + { + // Arrange + string[] expected = + [ + "Sysvar1111111111111111111111111111111111111", + "SysvarC1ock11111111111111111111111111111111", + "SysvarEpochRewards1111111111111111111111111", + "SysvarEpochSchedu1e111111111111111111111111", + "SysvarFees111111111111111111111111111111111", + "Sysvar1nstructions1111111111111111111111111", + "SysvarLastRestartS1ot1111111111111111111111", + "SysvarRecentB1ockHashes11111111111111111111", + "SysvarRent111111111111111111111111111111111", + "SysvarRewards111111111111111111111111111111", + "SysvarS1otHashes111111111111111111111111111", + "SysvarS1otHistory11111111111111111111111111", + "SysvarStakeHistory1111111111111111111111111" + ]; + + // Act + string[] actual = + [ + Sysvars.Owner, + Sysvars.Clock, + Sysvars.EpochRewards, + Sysvars.EpochSchedule, + Sysvars.Fees, + Sysvars.Instructions, + Sysvars.LastRestartSlot, + Sysvars.RecentBlockhashes, + Sysvars.Rent, + Sysvars.Rewards, + Sysvars.SlotHashes, + Sysvars.SlotHistory, + Sysvars.StakeHistory + ]; + + // Assert + actual.Should().Equal(expected); + } + } +} diff --git a/tests/SolSharp.IntegrationTests/DevnetWriteIntegrationTests.cs b/tests/SolSharp.IntegrationTests/DevnetWriteIntegrationTests.cs index ffa07a9..b6ea5a8 100644 --- a/tests/SolSharp.IntegrationTests/DevnetWriteIntegrationTests.cs +++ b/tests/SolSharp.IntegrationTests/DevnetWriteIntegrationTests.cs @@ -27,6 +27,9 @@ private static ServiceProvider CreateProvider() /// Funds a fresh keypair via airdrop and waits for the deposit to confirm. private static async Task FundedPayerAsync(SolanaRpcClient client, ulong lamports) { + var genesisHash = await client.GetGenesisHashAsync(); + IntegrationEnvironment.ValidateDevnetGenesisHash(genesisHash); + var payer = Keypair.Generate(); var signature = await client.RequestAirdropAsync(payer.PublicKey, lamports); await client.ConfirmTransactionAsync(signature, timeout: ConfirmTimeout); diff --git a/tests/SolSharp.IntegrationTests/IntegrationEnvironment.cs b/tests/SolSharp.IntegrationTests/IntegrationEnvironment.cs index 016a28a..a226fce 100644 --- a/tests/SolSharp.IntegrationTests/IntegrationEnvironment.cs +++ b/tests/SolSharp.IntegrationTests/IntegrationEnvironment.cs @@ -13,6 +13,8 @@ namespace SolSharp.IntegrationTests; /// internal static class IntegrationEnvironment { + private const string StrictModeVariable = "SOLSHARP_INTEGRATION_STRICT"; + /// The public mainnet JSON-RPC endpoint used when SOLSHARP_RPC_URL is not set. public const string DefaultHttpEndpoint = "https://api.mainnet-beta.solana.com"; @@ -22,6 +24,9 @@ internal static class IntegrationEnvironment /// The public devnet JSON-RPC endpoint used when SOLSHARP_DEVNET_RPC_URL is not set. public const string DefaultDevnetHttpEndpoint = "https://api.devnet.solana.com"; + /// The canonical genesis hash of the Solana devnet cluster. + public const string DevnetGenesisHash = "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG"; + /// The HTTP JSON-RPC endpoint the read tests talk to. public static string HttpEndpoint => Resolve("SOLSHARP_RPC_URL", DefaultHttpEndpoint); @@ -34,13 +39,18 @@ internal static class IntegrationEnvironment /// public static string DevnetHttpEndpoint => Resolve("SOLSHARP_DEVNET_RPC_URL", DefaultDevnetHttpEndpoint); + private static bool IsStrict => + Environment.GetEnvironmentVariable(StrictModeVariable) is { } value + && (value.Equals("true", StringComparison.OrdinalIgnoreCase) || value == "1"); + private static string Resolve(string variable, string fallback) => Environment.GetEnvironmentVariable(variable) is { Length: > 0 } value ? value : fallback; /// /// Runs an RPC call, turning a transient failure (a rate limit, timeout, or node hiccup) into an /// inconclusive result rather than a failure - a busy public node should not turn the suite red. A - /// non-transient exception (a parsing bug, say) is left to propagate and fail the test. + /// non-transient exception (a parsing bug, say) is left to propagate and fail the test. Setting + /// SOLSHARP_INTEGRATION_STRICT to true or 1 also propagates transient failures. /// /// The call's result type. /// The RPC call to run. @@ -53,6 +63,9 @@ public static async Task CallAsync(Func> call) } catch (Exception exception) when (IsTransient(exception)) { + if (IsStrict) + ExceptionDispatchInfo.Capture(exception).Throw(); + Assert.Inconclusive($"Skipped: the RPC endpoint was unavailable or rate-limited ({Describe(exception)})."); throw; // unreachable: Assert.Inconclusive always throws. } @@ -60,23 +73,49 @@ public static async Task CallAsync(Func> call) /// /// Whether reflects a transient transport problem (a rate limit, timeout, - /// broken connection or socket, rejected WebSocket handshake, resilience-pipeline rejection, or an - /// RPC-level error) as opposed to a real defect. + /// broken connection or socket, rejected WebSocket handshake, resilience-pipeline rejection, or a + /// specifically transient RPC error) as opposed to a real defect. /// /// The exception to classify. /// true when the failure should be treated as transient. public static bool IsTransient(Exception exception) - => exception is HttpRequestException or TaskCanceledException or TimeoutException or OperationCanceledException or RpcException + => exception is HttpRequestException or TaskCanceledException or TimeoutException or OperationCanceledException or WebSocketException or SocketException + || exception is RpcException { Code: -32603 or -32004 or -32005 or -32007 or -32009 or -32014 or -32016 } || exception.GetType().FullName?.StartsWith("Polly.", StringComparison.Ordinal) == true || (exception.InnerException is { } inner && IsTransient(inner)); - /// Rethrows unless it is transient, in which case the test is marked inconclusive. + /// + /// Rejects an endpoint whose genesis hash is not the canonical devnet hash. Write tests call this + /// before generating a payer or requesting an airdrop, so an accidental mainnet/testnet override + /// cannot submit a transaction. + /// + /// The endpoint's getGenesisHash result. + /// The endpoint is not the canonical devnet cluster. + public static void ValidateDevnetGenesisHash(string actualGenesisHash) + { + if (!string.Equals(actualGenesisHash, DevnetGenesisHash, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The write-test endpoint is not Solana devnet (expected genesis {DevnetGenesisHash}, " + + $"received {actualGenesisHash}). No write was attempted."); + } + } + + /// + /// Rethrows unless it is transient, in which case the test is marked + /// inconclusive. Strict integration mode also rethrows transient exceptions. + /// /// The exception captured from a network operation. public static void RethrowOrInconclusive(Exception exception) { if (IsTransient(exception)) + { + if (IsStrict) + ExceptionDispatchInfo.Capture(exception).Throw(); + Assert.Inconclusive($"Skipped: the endpoint was unavailable or rate-limited ({Describe(exception)})."); + } else ExceptionDispatchInfo.Capture(exception).Throw(); } diff --git a/tests/SolSharp.IntegrationTests/IntegrationEnvironmentTests.cs b/tests/SolSharp.IntegrationTests/IntegrationEnvironmentTests.cs new file mode 100644 index 0000000..11a5e68 --- /dev/null +++ b/tests/SolSharp.IntegrationTests/IntegrationEnvironmentTests.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Rpc.Protocol; + +namespace SolSharp.IntegrationTests; + +internal static class IntegrationEnvironmentTests +{ + [TestFixture] + public sealed class IsTransient + { + [TestCase(-32601)] // Method not found. + [TestCase(-32602)] // Invalid params. + [TestCase(-32002)] // Transaction simulation failed. + [TestCase(-32003)] // Signature verification failure. + public void DeterministicRpcFailure_ReturnsFalse(int code) + { + // Act + var result = IntegrationEnvironment.IsTransient(new RpcException(code, "deterministic")); + + // Assert + result.Should().BeFalse(); + } + + [TestCase(-32603)] // Internal error. + [TestCase(-32004)] // Block not available for slot. + [TestCase(-32005)] // Node is unhealthy. + [TestCase(-32007)] // Slot skipped or missing due to ledger jump. + [TestCase(-32009)] // Slot missing in long-term storage. + [TestCase(-32014)] // Block status is not available yet. + [TestCase(-32016)] // Minimum context slot has not been reached. + public void TransientRpcFailure_ReturnsTrue(int code) + { + // Act + var result = IntegrationEnvironment.IsTransient(new RpcException(code, "transient")); + + // Assert + result.Should().BeTrue(); + } + } + + [TestFixture] + public sealed class ValidateDevnetGenesisHash + { + [Test] + public void CanonicalDevnetHash_DoesNotThrow() + { + // Act + var act = () => IntegrationEnvironment.ValidateDevnetGenesisHash( + IntegrationEnvironment.DevnetGenesisHash); + + // Assert + act.Should().NotThrow(); + } + + [TestCase("5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d")] + [TestCase("4uhcVJyU9pJkvQyS88uRDiswHXSCkY3zQawwpjk2NsNY")] + [TestCase("")] + public void NonDevnetHash_ThrowsBeforeAnyWrite(string genesisHash) + { + // Act + var act = () => IntegrationEnvironment.ValidateDevnetGenesisHash(genesisHash); + + // Assert + act.Should().Throw() + .WithMessage("*No write was attempted*"); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/AddressLookupTableProgramTests.cs b/tests/SolSharp.Programs.Tests/AddressLookupTableProgramTests.cs index 13bd699..e9ca861 100644 --- a/tests/SolSharp.Programs.Tests/AddressLookupTableProgramTests.cs +++ b/tests/SolSharp.Programs.Tests/AddressLookupTableProgramTests.cs @@ -36,7 +36,9 @@ public void DerivesTableAddress_MatchesSolders_AndEncodesData() instruction.Accounts[0].IsWritable.Should().BeTrue(); instruction.Accounts[0].IsSigner.Should().BeFalse(); instruction.Accounts[1].PublicKey.Should().Be(Pk(1)); - instruction.Accounts[1].IsSigner.Should().BeTrue(); + // Since Solana activated relax_authority_signer_check_for_lookup_table_creation on every + // cluster, the canonical Rust builder does not require the future authority to sign creation. + instruction.Accounts[1].IsSigner.Should().BeFalse(); instruction.Accounts[1].IsWritable.Should().BeFalse(); instruction.Accounts[2].PublicKey.Should().Be(Pk(2)); instruction.Accounts[2].IsSigner.Should().BeTrue(); diff --git a/tests/SolSharp.Programs.Tests/AddressLookupTableStateTests.cs b/tests/SolSharp.Programs.Tests/AddressLookupTableStateTests.cs new file mode 100644 index 0000000..8d3fc5e --- /dev/null +++ b/tests/SolSharp.Programs.Tests/AddressLookupTableStateTests.cs @@ -0,0 +1,340 @@ +using System.Buffers.Binary; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; +using SolSharp.Core.SysvarStates; + +namespace SolSharp.Programs.Tests; + +public static class AddressLookupTableStateTests +{ + private static PublicKey Pk(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + private static AddressLookupTableState Table( + ulong deactivationSlot = ulong.MaxValue, + ulong lastExtendedSlot = 10, + byte lastExtendedSlotStartIndex = 1, + int addressCount = 3) + { + var data = new byte[AddressLookupTableState.MetadataLength + (addressCount * PublicKey.Length)]; + BinaryPrimitives.WriteUInt32LittleEndian(data, 1); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(4), deactivationSlot); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(12), lastExtendedSlot); + data[20] = lastExtendedSlotStartIndex; + data[21] = 0; + for (var i = 0; i < addressCount; i++) + Pk((byte)(i + 1)).CopyTo(data.AsSpan(AddressLookupTableState.MetadataLength + (i * PublicKey.Length))); + return AddressLookupTableState.Parse(data); + } + + private static SlotHashesSysvarState SlotHashes(params ulong[] slots) + { + var data = new byte[sizeof(ulong) + (slots.Length * (sizeof(ulong) + Hash.Length))]; + BinaryPrimitives.WriteUInt64LittleEndian(data, checked((ulong)slots.Length)); + var offset = sizeof(ulong); + foreach (var slot in slots) + { + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(offset), slot); + offset += sizeof(ulong) + Hash.Length; + } + + return SlotHashesSysvarState.Parse(data); + } + + [TestFixture] + public sealed class Parse + { + [Test] + public void InitializedTable_DecodesPinned56ByteMetadataLayoutAndAddresses() + { + // Arrange + var data = new byte[AddressLookupTableState.MetadataLength + (2 * PublicKey.Length)]; + BinaryPrimitives.WriteUInt32LittleEndian(data, 1); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(4), 11); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(12), 12); + data[20] = 1; + data[21] = 1; + Pk(3).CopyTo(data.AsSpan(22)); + Pk(4).CopyTo(data.AsSpan(56)); + Pk(5).CopyTo(data.AsSpan(88)); + + // Act + var state = AddressLookupTableState.Parse(data); + + // Assert + state.Kind.Should().Be(AddressLookupTableStateKind.LookupTable); + state.DeactivationSlot.Should().Be(11); + state.LastExtendedSlot.Should().Be(12); + state.LastExtendedSlotStartIndex.Should().Be(1); + state.Authority.Should().Be(Pk(3)); + state.Addresses.Should().Equal(Pk(4), Pk(5)); + } + + [Test] + public void FrozenTable_DecodesNoneAuthority_AndRejectsMisalignedAddresses() + { + // Arrange + var frozen = new byte[AddressLookupTableState.MetadataLength]; + BinaryPrimitives.WriteUInt32LittleEndian(frozen, 1); + frozen[21] = 0; + var misaligned = new byte[AddressLookupTableState.MetadataLength + 1]; + frozen.CopyTo(misaligned, 0); + + // Act + var state = AddressLookupTableState.Parse(frozen); + Action act = () => AddressLookupTableState.Parse(misaligned); + + // Assert + state.Authority.Should().BeNull(); + act.Should().Throw(); + } + + [Test] + public void MalformedMetadata_IsRejectedWithTheDocumentedExceptionTypes() + { + // Arrange + var truncated = new byte[AddressLookupTableState.MetadataLength - 1]; + BinaryPrimitives.WriteUInt32LittleEndian(truncated, 1); + var unknownDiscriminator = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(unknownDiscriminator, 2); + var invalidAuthority = new byte[AddressLookupTableState.MetadataLength]; + BinaryPrimitives.WriteUInt32LittleEndian(invalidAuthority, 1); + invalidAuthority[21] = 2; + var tooManyAddresses = new byte[ + AddressLookupTableState.MetadataLength + + ((AddressLookupTableState.MaximumAddresses + 1) * PublicKey.Length)]; + BinaryPrimitives.WriteUInt32LittleEndian(tooManyAddresses, 1); + + // Act + Action parseTruncated = () => _ = AddressLookupTableState.Parse(truncated); + Action parseUnknown = () => _ = AddressLookupTableState.Parse(unknownDiscriminator); + Action parseInvalidAuthority = () => _ = AddressLookupTableState.Parse(invalidAuthority); + Action parseTooManyAddresses = () => _ = AddressLookupTableState.Parse(tooManyAddresses); + + // Assert + parseTruncated.Should().Throw(); + parseUnknown.Should().Throw(); + parseInvalidAuthority.Should().Throw(); + parseTooManyAddresses.Should().Throw(); + } + } + + [TestFixture] + public sealed class TryParse + { + [Test] + public void ValidAndMalformedStates_ReturnSuccessAndFailureWithoutThrowing() + { + // Arrange + var valid = new byte[AddressLookupTableState.MetadataLength]; + BinaryPrimitives.WriteUInt32LittleEndian(valid, 1); + var malformed = new byte[1]; + + // Act + var validResult = AddressLookupTableState.TryParse(valid, out var state); + var malformedResult = AddressLookupTableState.TryParse(malformed, out var malformedState); + + // Assert + validResult.Should().BeTrue(); + state.Should().NotBeNull(); + state!.Kind.Should().Be(AddressLookupTableStateKind.LookupTable); + malformedResult.Should().BeFalse(); + malformedState.Should().BeNull(); + } + } + + [TestFixture] + public sealed class EstimateLastValidSlot + { + [Test] + public void NormalAndOverflowingSlots_MatchSaturatingUpstreamEstimate() + { + // Act & Assert + AddressLookupTableState.EstimateLastValidSlot(1_000).Should().Be(1_512); + AddressLookupTableState.EstimateLastValidSlot(ulong.MaxValue - 10).Should().Be(ulong.MaxValue); + } + } + + [TestFixture] + public sealed class GetStatus + { + [Test] + public void ActivationBranches_MatchSlotHashesPositionSemantics() + { + // Arrange + var slotHashes = SlotHashes(90, 80, 70); + + // Act + var activated = Table().GetStatus(100, slotHashes); + var justDeactivating = Table(deactivationSlot: 100).GetStatus(100, slotHashes); + var coolingDown = Table(deactivationSlot: 80).GetStatus(100, slotHashes); + var deactivated = Table(deactivationSlot: 60).GetStatus(100, slotHashes); + + // Assert + activated.Should().Be(new AddressLookupTableStatus(AddressLookupTableStatusKind.Activated)); + justDeactivating.Should().Be(new AddressLookupTableStatus( + AddressLookupTableStatusKind.Deactivating, + SlotHashesSysvarState.MaximumEntries + 1)); + coolingDown.Should().Be(new AddressLookupTableStatus( + AddressLookupTableStatusKind.Deactivating, + SlotHashesSysvarState.MaximumEntries - 1)); + deactivated.Should().Be(new AddressLookupTableStatus(AddressLookupTableStatusKind.Deactivated)); + } + + [Test] + public void OldestRetainedAndEvictedSlots_MatchTheFullRuntimeCapacityBoundary() + { + // Arrange + var retainedSlots = Enumerable.Range(1, SlotHashesSysvarState.MaximumEntries) + .Reverse() + .Select(slot => (ulong)slot) + .ToArray(); + var afterEviction = Enumerable.Range(2, SlotHashesSysvarState.MaximumEntries) + .Reverse() + .Select(slot => (ulong)slot) + .ToArray(); + var table = Table(deactivationSlot: 1); + + // Act + var oldestRetained = table.GetStatus(600, SlotHashes(retainedSlots)); + var evicted = table.GetStatus(600, SlotHashes(afterEviction)); + + // Assert + oldestRetained.Should().Be(new AddressLookupTableStatus( + AddressLookupTableStatusKind.Deactivating, + RemainingBlocks: 1)); + evicted.Should().Be(new AddressLookupTableStatus(AddressLookupTableStatusKind.Deactivated)); + } + + [Test] + public void UninitializedState_Throws() + { + // Arrange + var state = AddressLookupTableState.Parse(new byte[sizeof(uint)]); + + // Act + Action act = () => _ = state.GetStatus(1, SlotHashes()); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class IsActive + { + [Test] + public void CoolingDownIsUsable_ButExpiredIsNot() + { + // Arrange + var slotHashes = SlotHashes(90, 80, 70); + + // Act & Assert + Table(deactivationSlot: 80).IsActive(100, slotHashes).Should().BeTrue(); + Table(deactivationSlot: 60).IsActive(100, slotHashes).Should().BeFalse(); + } + } + + [TestFixture] + public sealed class GetActiveAddressesLength + { + [Test] + public void SameSlotUsesPreExtensionPrefix_AndLaterSlotUsesAllAddresses() + { + // Arrange + var table = Table(lastExtendedSlot: 10, lastExtendedSlotStartIndex: 1); + var slotHashes = SlotHashes(); + + // Act & Assert + table.GetActiveAddressesLength(10, slotHashes).Should().Be(1); + table.GetActiveAddressesLength(11, slotHashes).Should().Be(3); + } + + [Test] + public void DeactivatedTable_Throws() + { + // Arrange + var table = Table(deactivationSlot: 5); + + // Act + Action act = () => _ = table.GetActiveAddressesLength(100, SlotHashes()); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class GetActiveAddresses + { + [Test] + public void ReturnsDefensiveCopyOfVisiblePrefix() + { + // Arrange + var table = Table(lastExtendedSlotStartIndex: 1); + + // Act + var addresses = table.GetActiveAddresses(10, SlotHashes()); + addresses[0] = Pk(99); + + // Assert + table.GetActiveAddresses(10, SlotHashes()).Should().Equal(Pk(1)); + } + + [Test] + public void MalformedPrefix_Throws() + { + // Arrange + var table = Table(lastExtendedSlotStartIndex: 4, addressCount: 3); + + // Act + Action act = () => _ = table.GetActiveAddresses(10, SlotHashes()); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class Lookup + { + [Test] + public void ActiveIndexes_PreserveCallerOrder() + { + // Arrange + var table = Table(); + + // Act + var addresses = table.Lookup(11, [2, 0], SlotHashes()); + + // Assert + addresses.Should().Equal(Pk(3), Pk(1)); + } + + [Test] + public void SameSlotHiddenIndex_Throws() + { + // Arrange + var table = Table(lastExtendedSlotStartIndex: 1); + + // Act + Action act = () => _ = table.Lookup(10, [1], SlotHashes()); + + // Assert + act.Should().Throw(); + } + + [Test] + public void DeactivatingTable_RemainsUsableDuringCooldown() + { + // Arrange + var table = Table(deactivationSlot: 100); + + // Act + var addresses = table.Lookup(100, [2, 0], SlotHashes()); + + // Assert + addresses.Should().Equal(Pk(3), Pk(1)); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/AssociatedTokenAccountTests.cs b/tests/SolSharp.Programs.Tests/AssociatedTokenAccountTests.cs index f693ac8..bc9b03c 100644 --- a/tests/SolSharp.Programs.Tests/AssociatedTokenAccountTests.cs +++ b/tests/SolSharp.Programs.Tests/AssociatedTokenAccountTests.cs @@ -43,7 +43,7 @@ public void ProducesExpectedAccountsAndAddress() // Assert instruction.ProgramId.Should().Be(AssociatedTokenAccount.ProgramId); - instruction.Data.Should().BeEmpty(); + instruction.Data.Should().Equal((byte)0); instruction.Accounts.Should().HaveCount(6); instruction.Accounts[0].PublicKey.Should().Be(payer); @@ -85,4 +85,35 @@ public void MatchesCreateWithIdempotentTag() idempotent.Accounts.Should().Equal(create.Accounts); } } + + [TestFixture] + public sealed class RecoverNested + { + [Test] + public void MatchesPinnedAssociatedTokenAccountInterface() + { + // Arrange + var wallet = Key(1); + var ownerMint = Key(2); + var nestedMint = Key(3); + var ownerAssociatedAccount = AssociatedTokenAccount.GetAddress(wallet, ownerMint); + var destinationAssociatedAccount = AssociatedTokenAccount.GetAddress(wallet, nestedMint); + var nestedAssociatedAccount = AssociatedTokenAccount.GetAddress(ownerAssociatedAccount, nestedMint); + + // Act + var instruction = AssociatedTokenAccount.RecoverNested(wallet, ownerMint, nestedMint); + + // Assert + instruction.ProgramId.Should().Be(AssociatedTokenAccount.ProgramId); + instruction.Data.Should().Equal((byte)2); + instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable)).Should().Equal( + (nestedAssociatedAccount, false, true), + (nestedMint, false, false), + (destinationAssociatedAccount, false, true), + (ownerAssociatedAccount, false, false), + (ownerMint, false, false), + (wallet, true, true), + (TokenProgram.ProgramId, false, false)); + } + } } diff --git a/tests/SolSharp.Programs.Tests/ConfidentialProofLocationTests.cs b/tests/SolSharp.Programs.Tests/ConfidentialProofLocationTests.cs new file mode 100644 index 0000000..8d38ef0 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/ConfidentialProofLocationTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class ConfidentialProofLocationTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + [TestFixture] + public sealed class AtInstructionOffset + { + [Test] + public void SignedBoundaries_ExposeOffsetBranchAndRejectZero() + { + // Act + var minimum = ConfidentialProofLocation.AtInstructionOffset(sbyte.MinValue); + var maximum = ConfidentialProofLocation.AtInstructionOffset(sbyte.MaxValue); + Action zero = () => _ = ConfidentialProofLocation.AtInstructionOffset(0); + + // Assert + minimum.IsInstructionOffset.Should().BeTrue(); + minimum.InstructionOffset.Should().Be(sbyte.MinValue); + minimum.ContextStateAccount.Should().BeNull(); + maximum.IsInstructionOffset.Should().BeTrue(); + maximum.InstructionOffset.Should().Be(sbyte.MaxValue); + maximum.ContextStateAccount.Should().BeNull(); + zero.Should().Throw().WithParameterName("instructionOffset"); + } + } + + [TestFixture] + public sealed class AtContextState + { + [Test] + public void ContextBranch_ExposesZeroOffsetAndAccount() + { + // Arrange + var context = Key(9); + + // Act + var location = ConfidentialProofLocation.AtContextState(context); + + // Assert + location.IsInstructionOffset.Should().BeFalse(); + location.InstructionOffset.Should().Be(0); + location.ContextStateAccount.Should().Be(context); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/ElGamalProofProgramDirectCoverageTests.cs b/tests/SolSharp.Programs.Tests/ElGamalProofProgramDirectCoverageTests.cs new file mode 100644 index 0000000..f12f3ae --- /dev/null +++ b/tests/SolSharp.Programs.Tests/ElGamalProofProgramDirectCoverageTests.cs @@ -0,0 +1,75 @@ +using FluentAssertions; +using NUnit.Framework; + +namespace SolSharp.Programs.Tests; + +public static class ElGamalProofProgramDirectCoverageTests +{ + [TestFixture] + public sealed class TryDecodeInstruction + { + [Test] + public void DefinedDiscriminatorsAndBoundaries_AreDecodedExactly() + { + // Arrange + var defined = Enum.GetValues(); + + // Act & Assert + foreach (var expected in defined) + { + ElGamalProofProgram.TryDecodeInstruction([(byte)expected, 0xff], out var actual) + .Should().BeTrue(); + actual.Should().Be(expected); + } + + ElGamalProofProgram.TryDecodeInstruction([], out var empty).Should().BeFalse(); + empty.Should().Be(default); + ElGamalProofProgram.TryDecodeInstruction([13], out var next).Should().BeFalse(); + next.Should().Be(default); + ElGamalProofProgram.TryDecodeInstruction([byte.MaxValue], out var maximum).Should().BeFalse(); + maximum.Should().Be(default); + } + } + + [TestFixture] + public sealed class GetProofDataLength + { + [Test] + public void VerifierVariants_ReturnPinnedPodLengths() + { + // Arrange + (ElGamalProofInstruction Instruction, int Length)[] expected = + [ + (ElGamalProofInstruction.VerifyZeroCiphertext, 192), + (ElGamalProofInstruction.VerifyCiphertextCiphertextEquality, 416), + (ElGamalProofInstruction.VerifyCiphertextCommitmentEquality, 320), + (ElGamalProofInstruction.VerifyPubkeyValidity, 96), + (ElGamalProofInstruction.VerifyPercentageWithCap, 360), + (ElGamalProofInstruction.VerifyBatchedRangeProofU64, 936), + (ElGamalProofInstruction.VerifyBatchedRangeProofU128, 1000), + (ElGamalProofInstruction.VerifyBatchedRangeProofU256, 1064), + (ElGamalProofInstruction.VerifyGroupedCiphertext2HandlesValidity, 320), + (ElGamalProofInstruction.VerifyBatchedGroupedCiphertext2HandlesValidity, 416), + (ElGamalProofInstruction.VerifyGroupedCiphertext3HandlesValidity, 416), + (ElGamalProofInstruction.VerifyBatchedGroupedCiphertext3HandlesValidity, 544) + ]; + + // Act & Assert + foreach (var (instruction, length) in expected) + ElGamalProofProgram.GetProofDataLength(instruction).Should().Be(length); + } + + [Test] + public void CloseAndUnknownDiscriminators_AreRejected() + { + // Act + Action close = () => _ = ElGamalProofProgram.GetProofDataLength( + ElGamalProofInstruction.CloseContextState); + Action unknown = () => _ = ElGamalProofProgram.GetProofDataLength((ElGamalProofInstruction)13); + + // Assert + close.Should().Throw().WithParameterName("proofInstruction"); + unknown.Should().Throw().WithParameterName("proofInstruction"); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/ExtraAccountMetaDirectCoverageTests.cs b/tests/SolSharp.Programs.Tests/ExtraAccountMetaDirectCoverageTests.cs new file mode 100644 index 0000000..dc8d62b --- /dev/null +++ b/tests/SolSharp.Programs.Tests/ExtraAccountMetaDirectCoverageTests.cs @@ -0,0 +1,132 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class ExtraAccountSeedDirectCoverageTests +{ + [TestFixture] + public sealed class DecodeConfiguration + { + [Test] + public void MixedPinnedSeedLayout_DecodesAllPublicFields() + { + // Arrange + var configuration = new byte[ExtraAccountMeta.AddressConfigurationLength]; + new byte[] { 1, 2, (byte)'a', (byte)'b', 2, 8, 4, 3, 5, 4, 6, 7, 8 } + .CopyTo(configuration, 0); + + // Act + var seeds = ExtraAccountSeed.DecodeConfiguration(configuration); + + // Assert + seeds.Should().HaveCount(4); + seeds![0].Kind.Should().Be(ExtraAccountSeedKind.Literal); + seeds[0].LiteralBytes.ToArray().Should().Equal("ab"u8.ToArray()); + seeds[1].Kind.Should().Be(ExtraAccountSeedKind.InstructionData); + seeds[1].Index.Should().Be(8); + seeds[1].Length.Should().Be(4); + seeds[2].Kind.Should().Be(ExtraAccountSeedKind.AccountKey); + seeds[2].Index.Should().Be(5); + seeds[3].Kind.Should().Be(ExtraAccountSeedKind.AccountData); + seeds[3].AccountIndex.Should().Be(6); + seeds[3].DataIndex.Should().Be(7); + seeds[3].Length.Should().Be(8); + } + + [Test] + public void WrongLengthUnknownTagOrOverrun_IsRejected() + { + // Arrange + var unknownTag = new byte[ExtraAccountMeta.AddressConfigurationLength]; + unknownTag[0] = 5; + var literalOverrun = new byte[ExtraAccountMeta.AddressConfigurationLength]; + literalOverrun[0] = 1; + literalOverrun[1] = 31; + + // Act & Assert + ExtraAccountSeed.DecodeConfiguration(new byte[ExtraAccountMeta.AddressConfigurationLength - 1]) + .Should().BeNull(); + ExtraAccountSeed.DecodeConfiguration(unknownTag).Should().BeNull(); + ExtraAccountSeed.DecodeConfiguration(literalOverrun).Should().BeNull(); + } + } +} + +public static class ExtraAccountMetaDirectCoverageTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + [TestFixture] + public sealed class FromExternalProgramDerivedAddress + { + [Test] + public void PinnedExternalProgramLayout_ExposesConfigurationAndPrivileges() + { + // Arrange + var seeds = new[] + { + ExtraAccountSeed.Literal("ab"u8), + ExtraAccountSeed.FromAccountData(1, 3, 4) + }; + var expectedConfiguration = new byte[ExtraAccountMeta.AddressConfigurationLength]; + new byte[] { 1, 2, (byte)'a', (byte)'b', 4, 1, 3, 4 } + .CopyTo(expectedConfiguration, 0); + + // Act + var meta = ExtraAccountMeta.FromExternalProgramDerivedAddress( + 7, seeds, isSigner: true, isWritable: false); + var decodedSeeds = meta.DecodeSeeds(); + + // Assert + meta.Discriminator.Should().Be(0x87); + meta.AddressConfiguration.ToArray().Should().Equal(expectedConfiguration); + meta.IsSigner.Should().BeTrue(); + meta.IsWritable.Should().BeFalse(); + meta.Encode().Should().Equal([0x87, .. expectedConfiguration, 1, 0]); + decodedSeeds.Should().HaveCount(2); + decodedSeeds![0].LiteralBytes.ToArray().Should().Equal("ab"u8.ToArray()); + decodedSeeds[1].AccountIndex.Should().Be(1); + decodedSeeds[1].DataIndex.Should().Be(3); + decodedSeeds[1].Length.Should().Be(4); + } + + [Test] + public void ProgramIndexBoundary_UsesHighBitWithoutOverflow() + { + // Act + var maximum = ExtraAccountMeta.FromExternalProgramDerivedAddress( + 127, [], isSigner: false, isWritable: false); + Action beyondMaximum = () => _ = ExtraAccountMeta.FromExternalProgramDerivedAddress( + 128, [], isSigner: false, isWritable: false); + + // Assert + maximum.Discriminator.Should().Be(byte.MaxValue); + beyondMaximum.Should().Throw().WithParameterName("programIndex"); + } + } + + [TestFixture] + public sealed class TryGetPublicKey + { + [Test] + public void FixedAndDerivedEntries_AreDistinguishedExactly() + { + // Arrange + var fixedEntry = ExtraAccountMeta.FromPublicKey(Key(6), isSigner: false, isWritable: true); + var derivedEntry = ExtraAccountMeta.FromProgramDerivedAddress( + [ExtraAccountSeed.FromAccountKey(0)], isSigner: false, isWritable: false); + + // Act + var fixedResult = fixedEntry.TryGetPublicKey(out var publicKey); + var derivedResult = derivedEntry.TryGetPublicKey(out var missingPublicKey); + + // Assert + fixedResult.Should().BeTrue(); + publicKey.Should().Be(Key(6)); + derivedResult.Should().BeFalse(); + missingPublicKey.Should().Be(default(PublicKey)); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/FeatureAccountStateTests.cs b/tests/SolSharp.Programs.Tests/FeatureAccountStateTests.cs new file mode 100644 index 0000000..b7550c2 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/FeatureAccountStateTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using NUnit.Framework; + +namespace SolSharp.Programs.Tests; + +public static class FeatureAccountStateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void RequestedButNotActive_MatchesPinnedSdkDefaultAccount() + { + // Arrange + byte[] data = [0, 0, 0, 0, 0, 0, 0, 0, 0]; + + // Act + var state = FeatureAccountState.Parse(data); + + // Assert + state.ActivatedAt.Should().BeNull(); + state.IsActive.Should().BeFalse(); + } + + [Test] + public void Activated_ReadsLittleEndianSlot() + { + // Arrange - bincode Option::Some(0x0807060504030201). + byte[] data = [1, 1, 2, 3, 4, 5, 6, 7, 8]; + + // Act + var state = FeatureAccountState.Parse(data); + + // Assert + state.ActivatedAt.Should().Be(0x0807060504030201UL); + state.IsActive.Should().BeTrue(); + } + + [TestCase(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 })] + [TestCase(new byte[] { 2, 0, 0, 0, 0, 0, 0, 0, 0 })] + public void MalformedData_IsRejected(byte[] data) + { + // Act + var parsed = FeatureAccountState.TryParse(data, out var state); + + // Assert + parsed.Should().BeFalse(); + state.Should().BeNull(); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/FeatureGateProgramTests.cs b/tests/SolSharp.Programs.Tests/FeatureGateProgramTests.cs new file mode 100644 index 0000000..32abe5d --- /dev/null +++ b/tests/SolSharp.Programs.Tests/FeatureGateProgramTests.cs @@ -0,0 +1,66 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class FeatureGateProgramTests +{ + private static readonly PublicKey Feature = new(Enumerable.Repeat((byte)1, 32).ToArray()); + private static readonly PublicKey Funder = new(Enumerable.Repeat((byte)2, 32).ToArray()); + + [TestFixture] + public sealed class ActivateWithLamports + { + [Test] + public void PinnedCompositeVector_MatchesTransferAllocateAssignSequence() + { + // Arrange + const ulong lamports = 0x0807060504030201; + + // Act + var instructions = FeatureGateProgram.ActivateWithLamports(Feature, Funder, lamports); + + // Assert + instructions.Should().HaveCount(3); + instructions.Select(instruction => instruction.ProgramId).Should().OnlyContain(id => id == SystemProgram.ProgramId); + instructions.Select(instruction => Convert.ToHexString(instruction.Data)).Should().Equal( + "020000000102030405060708", + "080000000900000000000000", + "01000000" + Convert.ToHexString(FeatureGateProgram.ProgramId.ToBytes())); + AssertMeta(instructions[0].Accounts[0], Funder, isSigner: true, isWritable: true); + AssertMeta(instructions[0].Accounts[1], Feature, isSigner: false, isWritable: true); + AssertMeta(instructions[1].Accounts.Single(), Feature, isSigner: true, isWritable: true); + AssertMeta(instructions[2].Accounts.Single(), Feature, isSigner: true, isWritable: true); + FeatureGateProgram.ProgramId.ToString().Should().Be("Feature111111111111111111111111111111111111"); + } + } + + [TestFixture] + public sealed class RevokePendingActivation + { + [Test] + public void PinnedCompositeVector_MatchesProgramDataAndAccountMetas() + { + // Act + var instruction = FeatureGateProgram.RevokePendingActivation(Feature); + + // Assert + instruction.ProgramId.Should().Be(FeatureGateProgram.ProgramId); + instruction.Data.Should().Equal(0); + instruction.Accounts.Should().HaveCount(3); + AssertMeta(instruction.Accounts[0], Feature, isSigner: true, isWritable: true); + AssertMeta(instruction.Accounts[1], FeatureGateProgram.IncineratorId, isSigner: false, isWritable: true); + AssertMeta(instruction.Accounts[2], SystemProgram.ProgramId, isSigner: false, isWritable: false); + FeatureGateProgram.IncineratorId.ToString().Should() + .Be("1nc1nerator11111111111111111111111111111111"); + } + } + + private static void AssertMeta(AccountMeta meta, PublicKey key, bool isSigner, bool isWritable) + { + meta.PublicKey.Should().Be(key); + meta.IsSigner.Should().Be(isSigner); + meta.IsWritable.Should().Be(isWritable); + } +} diff --git a/tests/SolSharp.Programs.Tests/InstructionsSysvarTests.cs b/tests/SolSharp.Programs.Tests/InstructionsSysvarTests.cs new file mode 100644 index 0000000..5c11d99 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/InstructionsSysvarTests.cs @@ -0,0 +1,147 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class InstructionsSysvarTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + [TestFixture] + public sealed class Serialize + { + [Test] + public void TwoInstructions_MatchPinnedRustLayoutAndRoundTrip() + { + // Arrange + var first = new Instruction + { + ProgramId = Key(2), + Accounts = [AccountMeta.WritableSigner(Key(1))], + Data = [0xAA, 0xBB] + }; + var second = new Instruction + { + ProgramId = Key(3), + Accounts = [], + Data = [0xCC] + }; + var expected = Convert.FromHexString( + "020006004d00" + + "010003" + string.Concat(Enumerable.Repeat("01", PublicKey.Length)) + + string.Concat(Enumerable.Repeat("02", PublicKey.Length)) + "0200aabb" + + "0000" + string.Concat(Enumerable.Repeat("03", PublicKey.Length)) + "0100cc" + + "0100"); + + // Act + var data = InstructionsSysvar.Serialize([first, second], currentInstructionIndex: 1); + var decodedFirst = InstructionsSysvar.ReadInstruction(data, 0); + var decodedRelative = InstructionsSysvar.ReadInstructionRelative(data, -1); + + // Assert + data.Should().Equal(expected); + InstructionsSysvar.GetInstructionCount(data).Should().Be(2); + InstructionsSysvar.ReadCurrentInstructionIndex(data).Should().Be(1); + decodedFirst.ProgramId.Should().Be(first.ProgramId); + decodedFirst.Accounts.Should().Equal(first.Accounts); + decodedFirst.Data.Should().Equal(first.Data); + decodedRelative.ProgramId.Should().Be(first.ProgramId); + } + } + + [TestFixture] + public sealed class WriteCurrentInstructionIndex + { + [Test] + public void CurrentIndex_CanBeUpdatedInPlace() + { + // Arrange + var data = InstructionsSysvar.Serialize([]); + + // Act + InstructionsSysvar.WriteCurrentInstructionIndex(data, 7); + + // Assert + InstructionsSysvar.ReadCurrentInstructionIndex(data).Should().Be(7); + } + } + + [TestFixture] + public sealed class GetInstructionCount + { + [Test] + public void TruncatedTable_IsRejectedBeforeAllocation() + { + // Arrange + var truncatedTable = new byte[] { 1, 0, 0 }; + + // Act + var table = () => InstructionsSysvar.GetInstructionCount(truncatedTable); + + // Assert + table.Should().Throw(); + } + } + + [TestFixture] + public sealed class ReadInstruction + { + [Test] + public void ImpossibleAccountCount_IsRejectedBeforeAllocation() + { + // Arrange + var impossibleAccounts = Convert.FromHexString("01000400ffff"); + + // Act + var accounts = () => InstructionsSysvar.ReadInstruction(impossibleAccounts, 0); + + // Assert + accounts.Should().Throw(); + } + + [Test] + public void TruncatedInstructionData_IsRejectedBeforeAllocation() + { + // Arrange + var truncatedInstructionData = Convert.FromHexString( + "010004000000" + string.Concat(Enumerable.Repeat("01", PublicKey.Length)) + "0200aa"); + + // Act + var instructionData = () => InstructionsSysvar.ReadInstruction(truncatedInstructionData, 0); + + // Assert + instructionData.Should().Throw(); + } + + [Test] + public void AbsoluteIndex_IsBounded() + { + // Arrange + var data = InstructionsSysvar.Serialize([]); + + // Act + var absolute = () => InstructionsSysvar.ReadInstruction(data, 0); + + // Assert + absolute.Should().Throw(); + } + } + + [TestFixture] + public sealed class ReadInstructionRelative + { + [Test] + public void RelativeIndex_IsBounded() + { + // Arrange + var data = InstructionsSysvar.Serialize([]); + + // Act + var relative = () => InstructionsSysvar.ReadInstructionRelative(data, -1); + + // Assert + relative.Should().Throw(); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/LoaderProgramParityTests.cs b/tests/SolSharp.Programs.Tests/LoaderProgramParityTests.cs new file mode 100644 index 0000000..85d8639 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/LoaderProgramParityTests.cs @@ -0,0 +1,449 @@ +using System.Buffers.Binary; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; +using static SolSharp.Programs.Tests.LoaderProgramTestHelpers; + +namespace SolSharp.Programs.Tests; + +internal static class LoaderProgramTestHelpers +{ + internal static PublicKey Pk(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + internal static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + internal static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; +} + +public static class LegacyBpfLoaderProgramTests +{ + [TestFixture] + public sealed class Write + { + [Test] + public void MatchesLoaderV2Bincode() + { + // Act +#pragma warning disable CS0618 + var instruction = LegacyBpfLoaderProgram.Write(Pk(1), 0x12345678, [0xaa, 0xbb, 0xcc]); +#pragma warning restore CS0618 + + // Assert + Hex(instruction).Should().Be("00000000785634120300000000000000aabbcc"); + instruction.ProgramId.Should().Be(LegacyBpfLoaderProgram.ProgramId); + instruction.Accounts.Should().ContainSingle() + .Which.Should().Match(account => account.IsSigner && account.IsWritable); + } + } + + [TestFixture] + public sealed class Finalize + { + [Test] + public void MatchesLoaderV2Bincode() => +#pragma warning disable CS0618 + Hex(LegacyBpfLoaderProgram.Finalize(Pk(1))).Should().Be("01000000"); +#pragma warning restore CS0618 + + } +} + +public static class UpgradeableBpfLoaderProgramTests +{ + [TestFixture] + public sealed class GetProgramDataAddress + { + [Test] + public void DerivesCanonicalProgramDataPda() + { + // Arrange + var program = Pk(9); + var expected = ProgramDerivedAddress.FindProgramAddress( + [program.ToBytes()], UpgradeableBpfLoaderProgram.ProgramId).Address; + + // Act + var address = UpgradeableBpfLoaderProgram.GetProgramDataAddress(program); + + // Assert + address.Should().Be(expected); + } + } + + [TestFixture] + public sealed class CreateBuffer + { + [Test] + public void ComposesPinnedCreateAndInitializeInstructions() + { + // Arrange + const ulong lamports = 123; + const ulong programLength = 456; + var expectedCreate = SystemProgram.CreateAccount( + Pk(1), + Pk(2), + lamports, + checked((ulong)UpgradeableBpfLoaderState.BufferMetadataLength + programLength), + UpgradeableBpfLoaderProgram.ProgramId); + var expectedInitialize = UpgradeableBpfLoaderProgram.InitializeBuffer(Pk(2), Pk(3)); + + // Act + var instructions = UpgradeableBpfLoaderProgram.CreateBuffer( + Pk(1), Pk(2), Pk(3), lamports, programLength); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Should().BeEquivalentTo(expectedCreate); + instructions[1].Should().BeEquivalentTo(expectedInitialize); + } + + [Test] + public void MetadataLengthOverflow_Throws() + { + // Act + Action act = () => UpgradeableBpfLoaderProgram.CreateBuffer( + Pk(1), Pk(2), Pk(3), 1, ulong.MaxValue); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class InitializeBuffer + { + [Test] + public void MatchesPinnedWincodeBincodeCompatibilityVector() => + Hex(UpgradeableBpfLoaderProgram.InitializeBuffer(Pk(1), Pk(2))).Should().Be("00000000"); + } + + [TestFixture] + public sealed class Write + { + [Test] + public void MatchesPinnedWincodeBincodeCompatibilityVector() => + Hex(UpgradeableBpfLoaderProgram.Write(Pk(1), Pk(2), 0x12345678, [0xaa, 0xbb, 0xcc])) + .Should().Be("01000000785634120300000000000000aabbcc"); + } + + [TestFixture] + public sealed class DeployInstruction + { + [Test] + public void MatchesPinnedWincodeBincodeCompatibilityVector() => + Hex(UpgradeableBpfLoaderProgram.DeployInstruction(Pk(1), Pk(2), Pk(3), Pk(4), 42, true)) + .Should().Be("020000002a0000000000000001"); + } + + [TestFixture] + public sealed class Upgrade + { + [Test] + public void MatchesPinnedWincodeBincodeCompatibilityVector() => + Hex(UpgradeableBpfLoaderProgram.Upgrade(Pk(1), Pk(2), Pk(3), Pk(4), false)) + .Should().Be("0300000000"); + } + + [TestFixture] + public sealed class SetBufferAuthority + { + [Test] + public void MatchesPinnedWincodeBincodeCompatibilityVector() => + Hex(UpgradeableBpfLoaderProgram.SetBufferAuthority(Pk(1), Pk(2), Pk(3))) + .Should().Be("04000000"); + } + + [TestFixture] + public sealed class Close + { + [Test] + public void MatchesPinnedWincodeBincodeCompatibilityVector() => + Hex(UpgradeableBpfLoaderProgram.Close(Pk(1), Pk(2), Pk(3), Pk(4), true)) + .Should().Be("0500000001"); + } + + [TestFixture] + public sealed class ExtendProgram + { + [Test] + public void MatchesPinnedWincodeBincodeCompatibilityVector() => + Hex(UpgradeableBpfLoaderProgram.ExtendProgram(Pk(1), 10_240)) + .Should().Be("0600000000280000"); + } + + [TestFixture] + public sealed class SetBufferAuthorityChecked + { + [Test] + public void MatchesPinnedWincodeBincodeCompatibilityVector() => + Hex(UpgradeableBpfLoaderProgram.SetBufferAuthorityChecked(Pk(1), Pk(2), Pk(3))) + .Should().Be("07000000"); + } + + [TestFixture] + public sealed class DeployWithMaximumProgramLength + { + [Test] + public void ComposesPinnedCreateAndDeployInstructions() + { + // Arrange + const ulong lamports = 123; + const ulong maximumProgramLength = 456; + var expectedCreate = SystemProgram.CreateAccount( + Pk(1), + Pk(2), + lamports, + UpgradeableBpfLoaderState.ProgramMetadataLength, + UpgradeableBpfLoaderProgram.ProgramId); + var expectedDeploy = UpgradeableBpfLoaderProgram.DeployInstruction( + Pk(1), Pk(2), Pk(3), Pk(4), maximumProgramLength, closeBuffer: false); + + // Act + var instructions = UpgradeableBpfLoaderProgram.DeployWithMaximumProgramLength( + Pk(1), Pk(2), Pk(3), Pk(4), lamports, maximumProgramLength, closeBuffer: false); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Should().BeEquivalentTo(expectedCreate); + instructions[1].Should().BeEquivalentTo(expectedDeploy); + } + } + + [TestFixture] + public sealed class SetUpgradeAuthority + { + [Test] + public void NewAuthority_IsReadonlyAndDoesNotSign() + { + // Arrange + var programData = UpgradeableBpfLoaderProgram.GetProgramDataAddress(Pk(1)); + + // Act + var instruction = UpgradeableBpfLoaderProgram.SetUpgradeAuthority(Pk(1), Pk(2), Pk(3)); + + // Assert + instruction.Data.Should().Equal(4, 0, 0, 0); + Metas(instruction).Should().Equal( + (programData, false, true), + (Pk(2), true, false), + (Pk(3), false, false)); + } + + [Test] + public void NullAuthority_PermanentlyRevokesUpgradeAuthority() + { + // Arrange + var programData = UpgradeableBpfLoaderProgram.GetProgramDataAddress(Pk(1)); + + // Act + var instruction = UpgradeableBpfLoaderProgram.SetUpgradeAuthority(Pk(1), Pk(2), null); + + // Assert + Metas(instruction).Should().Equal( + (programData, false, true), + (Pk(2), true, false)); + } + } + + [TestFixture] + public sealed class SetUpgradeAuthorityChecked + { + [Test] + public void NewAuthority_IsRequiredToSign() + { + // Arrange + var programData = UpgradeableBpfLoaderProgram.GetProgramDataAddress(Pk(1)); + + // Act + var instruction = UpgradeableBpfLoaderProgram.SetUpgradeAuthorityChecked(Pk(1), Pk(2), Pk(3)); + + // Assert + instruction.Data.Should().Equal(7, 0, 0, 0); + Metas(instruction).Should().Equal( + (programData, false, true), + (Pk(2), true, false), + (Pk(3), true, false)); + } + } + + [TestFixture] + public sealed class CloseBuffer + { + [Test] + public void DelegatesToPinnedCloseBufferBranch() + { + // Act + var instruction = UpgradeableBpfLoaderProgram.CloseBuffer(Pk(1), Pk(2), Pk(3)); + + // Assert + instruction.Data.Should().Equal(5, 0, 0, 0, 0); + Metas(instruction).Should().Equal( + (Pk(1), false, true), + (Pk(2), false, true), + (Pk(3), true, false)); + } + } +} + +public static class UpgradeableBpfLoaderStateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void ProgramData_DecodesFixedMetadataAndTrailingBytes() + { + // Arrange + var data = new byte[UpgradeableBpfLoaderState.ProgramDataMetadataLength + 3]; + BinaryPrimitives.WriteUInt32LittleEndian(data, 3); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(4), 0x0102030405060708); + data[12] = 1; + Pk(7).CopyTo(data.AsSpan(13)); + data[^3] = 0xaa; + data[^2] = 0xbb; + data[^1] = 0xcc; + + // Act + var state = UpgradeableBpfLoaderState.Parse(data); + + // Assert + state.Kind.Should().Be(UpgradeableBpfLoaderStateKind.ProgramData); + state.Slot.Should().Be(0x0102030405060708); + state.Authority.Should().Be(Pk(7)); + state.ProgramBytes.ToArray().Should().Equal(0xaa, 0xbb, 0xcc); + } + + [Test] + public void Program_DecodesProgramDataAddressWithoutUnrelatedFields() + { + // Arrange + var data = new byte[UpgradeableBpfLoaderState.ProgramMetadataLength]; + BinaryPrimitives.WriteUInt32LittleEndian(data, 2); + Pk(8).CopyTo(data.AsSpan(sizeof(uint))); + + // Act + var state = UpgradeableBpfLoaderState.Parse(data); + + // Assert + state.Kind.Should().Be(UpgradeableBpfLoaderStateKind.Program); + state.ProgramDataAddress.Should().Be(Pk(8)); + state.Authority.Should().BeNull(); + state.Slot.Should().BeNull(); + state.ProgramBytes.ToArray().Should().BeEmpty(); + } + } +} + +public static class LoaderV4ProgramTests +{ + [TestFixture] + public sealed class CreateBuffer + { + [Test] + public void MatchesPinnedRustComposite() + { + // Arrange + const ulong lamports = 123; + const uint programLength = 456; + var expectedCreate = SystemProgram.CreateAccount( + Pk(1), Pk(2), lamports, 0, LoaderV4Program.ProgramId); + var expectedResize = LoaderV4Program.SetProgramLength(Pk(2), Pk(3), programLength, Pk(4)); + + // Act + var instructions = LoaderV4Program.CreateBuffer( + Pk(1), Pk(2), lamports, Pk(3), programLength, Pk(4)); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Should().BeEquivalentTo(expectedCreate); + instructions[1].Should().BeEquivalentTo(expectedResize); + } + } + + [TestFixture] + public sealed class Write + { + [Test] + public void MatchesPinnedBincodeVector() => + Hex(LoaderV4Program.Write(Pk(1), Pk(2), 0x12345678, [0xaa, 0xbb, 0xcc])) + .Should().Be("00000000785634120300000000000000aabbcc"); + } + + [TestFixture] + public sealed class Copy + { + [Test] + public void MatchesPinnedBincodeVector() => + Hex(LoaderV4Program.Copy(Pk(1), Pk(2), Pk(3), 1, 2, 3)) + .Should().Be("01000000010000000200000003000000"); + } + + [TestFixture] + public sealed class SetProgramLength + { + [Test] + public void MatchesPinnedBincodeVector() => + Hex(LoaderV4Program.SetProgramLength(Pk(1), Pk(2), 4, Pk(3))) + .Should().Be("0200000004000000"); + } + + [TestFixture] + public sealed class Deploy + { + [Test] + public void MatchesPinnedBincodeVector() => + Hex(LoaderV4Program.Deploy(Pk(1), Pk(2))).Should().Be("03000000"); + } + + [TestFixture] + public sealed class Retract + { + [Test] + public void MatchesPinnedBincodeVector() => + Hex(LoaderV4Program.Retract(Pk(1), Pk(2))).Should().Be("04000000"); + } + + [TestFixture] + public sealed class TransferAuthority + { + [Test] + public void MatchesPinnedBincodeVector() => + Hex(LoaderV4Program.TransferAuthority(Pk(1), Pk(2), Pk(3))).Should().Be("05000000"); + } + + [TestFixture] + public sealed class Finalize + { + [Test] + public void MatchesPinnedBincodeVector() => + Hex(LoaderV4Program.Finalize(Pk(1), Pk(2), Pk(3))).Should().Be("06000000"); + } +} + +public static class LoaderV4StateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void DecodesNativeFortyEightByteHeader() + { + // Arrange + var data = new byte[LoaderV4State.MetadataLength + 2]; + BinaryPrimitives.WriteUInt64LittleEndian(data, 123); + Pk(8).CopyTo(data.AsSpan(8)); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(40), (ulong)LoaderV4Status.Deployed); + data[^2] = 0xaa; + data[^1] = 0xbb; + + // Act + var state = LoaderV4State.Parse(data); + + // Assert + state.Slot.Should().Be(123); + state.AuthorityOrNextVersion.Should().Be(Pk(8)); + state.Status.Should().Be(LoaderV4Status.Deployed); + state.ProgramBytes.ToArray().Should().Equal(0xaa, 0xbb); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/MemoProgramTests.cs b/tests/SolSharp.Programs.Tests/MemoProgramTests.cs index 6dc52e4..5394f7a 100644 --- a/tests/SolSharp.Programs.Tests/MemoProgramTests.cs +++ b/tests/SolSharp.Programs.Tests/MemoProgramTests.cs @@ -45,5 +45,29 @@ public void WithoutSigners_HasNoAccounts() instruction.Accounts.Should().BeEmpty(); Convert.ToHexString(instruction.Data).ToLowerInvariant().Should().Be("68656c6c6f"); } + + [Test] + public void LoneSurrogate_ThrowsInsteadOfEncodingReplacementCharacter() + { + // Act + Action act = () => MemoProgram.Memo("\ud800"); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be("text"); + } + + [Test] + public void RawBytes_PreserveExactRustBuilderPayload() + { + // Act + var instruction = MemoProgram.Memo([0xff, 0x00, 0x80], Pk(6)); + + // Assert + instruction.Data.Should().Equal(0xff, 0x00, 0x80); + instruction.Accounts.Should().ContainSingle(); + instruction.Accounts[0].Should().Match(account => + account.PublicKey == Pk(6) && account.IsSigner && !account.IsWritable); + } } } diff --git a/tests/SolSharp.Programs.Tests/MessageDecompileTests.cs b/tests/SolSharp.Programs.Tests/MessageDecompileTests.cs index 1da60b1..3c9aba8 100644 --- a/tests/SolSharp.Programs.Tests/MessageDecompileTests.cs +++ b/tests/SolSharp.Programs.Tests/MessageDecompileTests.cs @@ -12,9 +12,8 @@ private static (PublicKey, bool, bool)[] Metas(Instruction instruction) => [.. instruction.Accounts.Select(a => (a.PublicKey, a.IsSigner, a.IsWritable))]; [TestFixture] - public sealed class Legacy + public sealed class MessageDecompileInstructions { - // Compile then decompile must round-trip an instruction touching all four account classes. [Test] public void ReproducesAllFourAccountClasses() { @@ -31,29 +30,86 @@ public void ReproducesAllFourAccountClasses() ], Data = [7] }; + var message = Message.Compile(Pk(1), Pk(8).ToString(), [instruction]); // Act - var message = Message.Compile(Pk(1), Pk(8).ToString(), [instruction]); + var decompiled = message.DecompileInstructions([]).Should().ContainSingle().Subject; // Assert - var decompiled = message.DecompileInstructions([]).Should().ContainSingle().Subject; decompiled.ProgramId.Should().Be(Pk(9)); - decompiled.Data.Should().Equal((byte)7); + decompiled.Data.Should().Equal(7); Metas(decompiled).Should().Equal( (Pk(1), true, true), (Pk(2), true, false), (Pk(3), false, true), (Pk(4), false, false)); + } - // The parameterless default (via the interface) works for a message with no lookup tables. - ((ITransactionMessage)message).DecompileInstructions().Should().ContainSingle(); + [Test] + public void MutatingDecompiledData_DoesNotMutateMessage() + { + // Arrange + var instruction = new Instruction { ProgramId = Pk(9), Accounts = [], Data = [7] }; + var message = Message.Compile(Pk(1), Pk(8).ToString(), [instruction]); + var decompiled = message.DecompileInstructions([]).Should().ContainSingle().Subject; + + // Act + decompiled.Data[0] = 99; + + // Assert + message.Instructions[0].Data.Should().Equal(7); + } + + [Test] + public void PreservesMultipleInstructionOrderAndRepeatedAccounts() + { + // Arrange + var repeated = Pk(3); + var first = new Instruction + { + ProgramId = Pk(9), + Accounts = [AccountMeta.Writable(repeated), AccountMeta.Writable(repeated)], + Data = [1] + }; + var second = new Instruction + { + ProgramId = Pk(10), + Accounts = [AccountMeta.Readonly(Pk(4))], + Data = [2, 3] + }; + var message = Message.Compile(Pk(1), Pk(8).ToString(), [first, second]); + + // Act + var decompiled = message.DecompileInstructions([]); + + // Assert + decompiled.Select(instruction => instruction.ProgramId).Should().Equal(Pk(9), Pk(10)); + decompiled.Select(instruction => instruction.Data).Should().SatisfyRespectively( + data => data.Should().Equal(1), + data => data.Should().Equal(2, 3)); + Metas(decompiled[0]).Should().Equal( + (repeated, false, true), + (repeated, false, true)); + Metas(decompiled[1]).Should().Equal((Pk(4), false, false)); + } + + [Test] + public void NullLookupTableList_ThrowsDocumentedException() + { + // Arrange + var message = Message.Compile(Pk(1), Pk(8).ToString(), []); + + // Act + Action act = () => _ = message.DecompileInstructions(null!); + + // Assert + act.Should().Throw().WithParameterName("lookupTables"); } } [TestFixture] - public sealed class Versioned + public sealed class MessageV0DecompileInstructions { - // Same instruction as MessageV0Tests (A=[2] drains writable, B=[3] drains readonly from table [5]). [Test] public void ResolvesLookupTableAccounts() { @@ -71,22 +127,19 @@ public void ResolvesLookupTableAccounts() Data = [1, 2] }; var table = new AddressLookupTableAccount(Pk(5), [Pk(2), Pk(3), Pk(7)]); + var message = MessageV0.Compile(Pk(1), Pk(8).ToString(), [instruction], [table]); // Act - var message = MessageV0.Compile(Pk(1), Pk(8).ToString(), [instruction], [table]); + var decompiled = message.DecompileInstructions([table]).Should().ContainSingle().Subject; // Assert - var decompiled = message.DecompileInstructions([table]).Should().ContainSingle().Subject; decompiled.ProgramId.Should().Be(Pk(9)); - decompiled.Data.Should().Equal((byte)1, 2); + decompiled.Data.Should().Equal(1, 2); Metas(decompiled).Should().Equal( (Pk(2), false, true), (Pk(3), false, false), (Pk(4), false, true), (Pk(6), true, true)); - - // Full index space = static (payer, signer, writable, program) ++ loaded-writable ++ loaded-readonly. - message.GetAccountKeys([table]).Should().Equal(Pk(1), Pk(6), Pk(4), Pk(9), Pk(2), Pk(3)); } [Test] @@ -108,5 +161,207 @@ public void WithoutTheTable_Throws() // Assert act.Should().Throw(); } + + [Test] + public void ResolvesMultipleTablesAndPreservesOriginalAccountOrder() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = + [ + AccountMeta.Readonly(Pk(13)), + AccountMeta.Writable(Pk(4)), + AccountMeta.Writable(Pk(12)), + AccountMeta.Readonly(Pk(3)), + AccountMeta.ReadonlySigner(Pk(6)), + AccountMeta.Writable(Pk(2)), + AccountMeta.WritableSigner(Pk(1)), + AccountMeta.Readonly(Pk(13)) + ], + Data = [4, 5] + }; + var firstTable = new AddressLookupTableAccount(Pk(5), [Pk(2), Pk(3)]); + var secondTable = new AddressLookupTableAccount(Pk(15), [Pk(12), Pk(13)]); + var message = MessageV0.Compile( + Pk(1), Pk(8).ToString(), [instruction], [firstTable, secondTable]); + + // Act + var decompiled = message.DecompileInstructions([firstTable, secondTable]) + .Should().ContainSingle().Subject; + + // Assert + message.AddressTableLookups.Should().HaveCount(2); + Metas(decompiled).Should().Equal( + (Pk(13), false, false), + (Pk(4), false, true), + (Pk(12), false, true), + (Pk(3), false, false), + (Pk(6), true, false), + (Pk(2), false, true), + (Pk(1), true, true), + (Pk(13), false, false)); + } + + [Test] + public void SuppliedTableWithMissingAddress_Throws() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = [AccountMeta.Writable(Pk(2))], + Data = [] + }; + var completeTable = new AddressLookupTableAccount(Pk(5), [Pk(2)]); + var message = MessageV0.Compile(Pk(1), Pk(8).ToString(), [instruction], [completeTable]); + var truncatedTable = new AddressLookupTableAccount(Pk(5), []); + + // Act + Action act = () => _ = message.DecompileInstructions([truncatedTable]); + + // Assert + act.Should().Throw().WithMessage("Lookup index 0 is out of range*"); + } + + [Test] + public void NullLookupTableList_ThrowsDocumentedException() + { + // Arrange + var message = MessageV0.Compile(Pk(1), Pk(8).ToString(), [], []); + + // Act + Action act = () => _ = message.DecompileInstructions(null!); + + // Assert + act.Should().Throw().WithParameterName("lookupTables"); + } + } + + [TestFixture] + public sealed class MessageV0GetAccountKeys + { + [Test] + public void LookupTables_ProduceTheCompleteStaticAndLoadedIndexSpace() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = + [ + AccountMeta.Writable(Pk(2)), + AccountMeta.Readonly(Pk(3)), + AccountMeta.Writable(Pk(4)), + AccountMeta.WritableSigner(Pk(6)) + ], + Data = [1, 2] + }; + var table = new AddressLookupTableAccount(Pk(5), [Pk(2), Pk(3), Pk(7)]); + var message = MessageV0.Compile(Pk(1), Pk(8).ToString(), [instruction], [table]); + + // Act + var keys = message.GetAccountKeys([table]); + + // Assert + keys.Should().Equal(Pk(1), Pk(6), Pk(4), Pk(9), Pk(2), Pk(3)); + } + + [Test] + public void NullLookupTableList_ThrowsDocumentedException() + { + // Arrange + var message = MessageV0.Compile(Pk(1), Pk(8).ToString(), [], []); + + // Act + Action act = () => _ = message.GetAccountKeys(null!); + + // Assert + act.Should().Throw().WithParameterName("lookupTables"); + } + } + + [TestFixture] + public sealed class MessageV1DecompileInstructions + { + [Test] + public void ParameterlessAndLookupTableOverloads_ResolveTheSameInlineAccounts() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = + [ + AccountMeta.WritableSigner(Pk(1)), + AccountMeta.Readonly(Pk(2)), + AccountMeta.Writable(Pk(3)) + ], + Data = [7] + }; + var message = MessageV1.Compile(Pk(1), new Hash(Pk(8).ToBytes()), [instruction]); + + // Act + var direct = message.DecompileInstructions().Should().ContainSingle().Subject; + var withLookupTables = message.DecompileInstructions([]).Should().ContainSingle().Subject; + + // Assert + direct.ProgramId.Should().Be(Pk(9)); + direct.Data.Should().Equal(7); + Metas(direct).Should().Equal( + (Pk(1), true, true), + (Pk(2), false, false), + (Pk(3), false, true)); + Metas(withLookupTables).Should().Equal(Metas(direct)); + } + + [Test] + public void NullLookupTableList_ThrowsDocumentedException() + { + // Arrange + var message = MessageV1.Compile(Pk(1), new Hash(Pk(8).ToBytes()), []); + + // Act + Action act = () => _ = message.DecompileInstructions(null!); + + // Assert + act.Should().Throw().WithParameterName("lookupTables"); + } + } + + [TestFixture] + public sealed class ITransactionMessageDecompileInstructions + { + [Test] + public void LegacyMessage_DefaultMethodUsesAnEmptyLookupTableList() + { + // Arrange + var instruction = new Instruction { ProgramId = Pk(9), Accounts = [], Data = [7] }; + var message = Message.Compile(Pk(1), Pk(8).ToString(), [instruction]); + + // Act & Assert + ((ITransactionMessage)message).DecompileInstructions().Should().ContainSingle(); + } + + [Test] + public void VersionZeroMessageWithLookups_DefaultMethodRejectsTheMissingTable() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = [AccountMeta.Writable(Pk(2))], + Data = [] + }; + var table = new AddressLookupTableAccount(Pk(5), [Pk(2)]); + var message = MessageV0.Compile(Pk(1), Pk(8).ToString(), [instruction], [table]); + + // Act + Action act = () => _ = ((ITransactionMessage)message).DecompileInstructions(); + + // Assert + act.Should().Throw(); + } } } diff --git a/tests/SolSharp.Programs.Tests/MessageTests.cs b/tests/SolSharp.Programs.Tests/MessageTests.cs index 5a86b36..9396bc0 100644 --- a/tests/SolSharp.Programs.Tests/MessageTests.cs +++ b/tests/SolSharp.Programs.Tests/MessageTests.cs @@ -11,9 +11,29 @@ public static class MessageTests private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + private static PublicKey UniqueKey(int value) + { + var bytes = new byte[PublicKey.Length]; + bytes[0] = (byte)value; + bytes[1] = (byte)(value >> 8); + return new PublicKey(bytes); + } + [TestFixture] public sealed class Compile { + private static Instruction AllSigners(int count, out PublicKey payer) + { + var keys = Enumerable.Range(0, count).Select(UniqueKey).ToArray(); + payer = keys[0]; + return new Instruction + { + ProgramId = keys[^1], + Accounts = [.. keys.Skip(1).Select(AccountMeta.ReadonlySigner)], + Data = [7] + }; + } + // Reference bytes generated with solders (the Rust solana-sdk): a System transfer of 1_000_000 // lamports from a fixed payer to a fixed recipient. [Test] @@ -48,6 +68,22 @@ public void SystemTransfer_MatchesSolanaSdk() "01020200010c0200000040420f0000000000")); } + [Test] + public void TypedBlockhash_MatchesStringOverload() + { + // Arrange + var payer = Key(1); + var instruction = new Instruction { ProgramId = Key(9), Accounts = [], Data = [7] }; + var blockhash = new Hash(Key(8).ToBytes()); + + // Act + var typed = Message.Compile(payer, blockhash, [instruction]); + var text = Message.Compile(payer, blockhash.ToString(), [instruction]); + + // Assert + typed.Serialize().Should().Equal(text.Serialize()); + } + // Reference bytes from solders for a case that exercises dedup, flag merging (an account that is // read-only in one instruction and writable in another becomes writable), all four account // classes, and the by-public-key ordering within each class. @@ -99,6 +135,46 @@ public void DedupMergeAndOrdering_MatchesSolanaSdk() "0303030303030303030303030303030303030303030303030303030303030303" + "020504000103040201020602020301aa")); } + + [Test] + public void OneHundredTwentySevenSigners_Compiles() + { + // Arrange + var instruction = AllSigners(MessageV0.VersionPrefix - 1, out var payer); + + // Act + var message = Message.Compile(payer, Key(8).ToString(), [instruction]); + + // Assert + message.RequiredSignatures.Should().Be(MessageV0.VersionPrefix - 1); + } + + [Test] + public void OneHundredTwentyEightSigners_ThrowsBeforeVersionBitCollision() + { + // Arrange + var instruction = AllSigners(MessageV0.VersionPrefix, out var payer); + + // Act + Action act = () => Message.Compile(payer, Key(8).ToString(), [instruction]); + + // Assert + act.Should().Throw().WithMessage("*at most 127 signatures*"); + } + + [Test] + public void MutatingSourceData_DoesNotMutateCompiledMessage() + { + // Arrange + var instruction = new Instruction { ProgramId = Key(9), Accounts = [], Data = [7] }; + var message = Message.Compile(Key(1), Key(8).ToString(), [instruction]); + + // Act + instruction.Data[0] = 99; + + // Assert + message.Instructions[0].Data.Should().Equal(7); + } } [TestFixture] @@ -156,6 +232,33 @@ public void TruncatedData_ThrowsFormatException() act.Should().Throw(); } + [Test] + public void ImpossibleAccountKeyCount_ThrowsBeforeAllocatingDeclaredArray() + { + // Arrange: max compact-u16 account count with no account bytes following it. + byte[] data = [1, 0, 0, 0xff, 0xff, 0x03]; + + // Act + Action act = () => Message.Deserialize(data); + + // Assert + act.Should().Throw().WithMessage("*declares 65535 account key(s)*"); + } + + [Test] + public void ImpossibleInstructionCount_ThrowsBeforeAllocatingDeclaredArray() + { + // Arrange: one fee-payer key and blockhash followed by max compact-u16 instructions, + // but no bytes for even one instruction's minimum representation. + byte[] data = [1, 0, 0, 1, .. Key(1).ToBytes(), .. Key(8).ToBytes(), 0xff, 0xff, 0x03]; + + // Act + Action act = () => Message.Deserialize(data); + + // Assert + act.Should().Throw().WithMessage("*declares 65535 instruction(s)*"); + } + [Test] public void ValidCompiledMessage_RoundTrips() { @@ -183,6 +286,21 @@ public void HeaderAreasOverlapAccountKeys_ThrowsFormatException() act.Should().Throw().WithMessage("*only 3 account key(s)*"); } + [Test] + public void SignerCountWithVersionBit_ThrowsFormatException() + { + // Arrange: the first high bit distinguishes versioned messages on the wire and is not a + // representable signer count in a canonical legacy message. + var data = SerializedTransfer(); + data[0] |= MessageV0.VersionPrefix; + + // Act + Action act = () => Message.Deserialize(data); + + // Assert + act.Should().Throw().WithMessage("*high bit marks a versioned message*"); + } + // Solana requires readonlySignedAccounts < requiredSignatures so at least one signer - the fee // payer - stays writable; (0, 0) additionally covers a message demanding no signatures at all. [TestCase((byte)1, (byte)1)] @@ -242,5 +360,18 @@ public void AccountIndexOutOfRange_ThrowsFormatException() // Assert act.Should().Throw().WithMessage("*account index 3*"); } + + [Test] + public void TrailingByte_ThrowsFormatException() + { + // Arrange + byte[] data = [.. SerializedTransfer(), 0xAA]; + + // Act + Action act = () => Message.Deserialize(data); + + // Assert + act.Should().Throw().WithMessage("*1 trailing byte(s)*"); + } } } diff --git a/tests/SolSharp.Programs.Tests/MessageV0Tests.cs b/tests/SolSharp.Programs.Tests/MessageV0Tests.cs index a6f9eec..8a94fd7 100644 --- a/tests/SolSharp.Programs.Tests/MessageV0Tests.cs +++ b/tests/SolSharp.Programs.Tests/MessageV0Tests.cs @@ -13,12 +13,32 @@ private static PublicKey Pk(byte value) return new PublicKey(bytes); } + private static PublicKey UniqueKey(int value) + { + var bytes = new byte[PublicKey.Length]; + bytes[0] = (byte)value; + bytes[1] = (byte)(value >> 8); + return new PublicKey(bytes); + } + // 32 bytes encode to the same base58 whether they represent a key or a blockhash. private static string Blockhash(byte value) => Pk(value).ToString(); [TestFixture] public sealed class Compile { + private static Instruction AllSigners(int count, out PublicKey payer) + { + var keys = Enumerable.Range(0, count).Select(UniqueKey).ToArray(); + payer = keys[0]; + return new Instruction + { + ProgramId = keys[^1], + Accounts = [.. keys.Skip(1).Select(AccountMeta.ReadonlySigner)], + Data = [] + }; + } + // KAT vs solders: MessageV0.try_compile(payer=[1], [ix], [alt], blockhash=[8]) -> to_bytes_versioned. // ix(program=[9], data=0102): A[2] writable, B[3] readonly, C[4] writable, D[6] writable signer. // alt=[5] holds [A, B, [7]] -> A drains writable (index 0), B drains readonly (index 1). @@ -74,6 +94,21 @@ public void WithNoLookupTables_MatchesSolders() Convert.ToHexString(message.Serialize()).ToLowerInvariant().Should().Be(expected); } + [Test] + public void TypedBlockhash_MatchesStringOverload() + { + // Arrange + var instruction = new Instruction { ProgramId = Pk(9), Accounts = [], Data = [7] }; + var blockhash = new Hash(Pk(8).ToBytes()); + + // Act + var typed = MessageV0.Compile(Pk(1), blockhash, [instruction], []); + var text = MessageV0.Compile(Pk(1), blockhash.ToString(), [instruction], []); + + // Assert + typed.Serialize().Should().Equal(text.Serialize()); + } + [Test] public void OversizedLookupTable_Throws() { @@ -96,6 +131,104 @@ public void OversizedLookupTable_Throws() // Assert act.Should().Throw().WithMessage("*at most 256*"); } + + [Test] + public void TwoHundredFiftyFiveSigners_Compiles() + { + // Arrange + var instruction = AllSigners(byte.MaxValue, out var payer); + + // Act + var message = MessageV0.Compile(payer, Blockhash(8), [instruction], []); + + // Assert + message.RequiredSignatures.Should().Be(byte.MaxValue); + } + + [Test] + public void TwoHundredFiftySixSigners_ThrowsInsteadOfWrapping() + { + // Arrange + var instruction = AllSigners(MessageV0.MaxAccounts, out var payer); + + // Act + Action act = () => MessageV0.Compile(payer, Blockhash(8), [instruction], []); + + // Assert + act.Should().Throw().WithMessage("*at most 255 signatures*"); + } + + [Test] + public void DurableNoncePresentInLookup_RemainsStatic() + { + // Arrange + var payer = Pk(1); + var nonce = Pk(2); + var advance = SystemProgram.AdvanceNonceAccount(nonce, payer); + var table = new AddressLookupTableAccount(Pk(5), [nonce]); + + // Act + var message = MessageV0.Compile(payer, Blockhash(8), [advance], [table]); + + // Assert + message.AccountKeys.Should().Contain(nonce); + message.AddressTableLookups.Should().BeEmpty(); + } + + [Test] + public void AdvanceNoncePrefixWithTrailingData_RemainsStatic_MatchingUpstream() + { + // Arrange + var payer = Pk(1); + var nonce = Pk(2); + var canonicalAdvance = SystemProgram.AdvanceNonceAccount(nonce, payer); + var advance = new Instruction + { + ProgramId = canonicalAdvance.ProgramId, + Accounts = canonicalAdvance.Accounts, + Data = [.. canonicalAdvance.Data, 0xAA] + }; + var table = new AddressLookupTableAccount(Pk(5), [nonce]); + + // Act + var message = MessageV0.Compile(payer, Blockhash(8), [advance], [table]); + + // Assert + message.AccountKeys.Should().Contain(nonce); + message.AddressTableLookups.Should().BeEmpty(); + } + + [Test] + public void NonFirstAdvanceNonce_DoesNotPinAccountStatic() + { + // Arrange + var payer = Pk(1); + var nonce = Pk(2); + var first = new Instruction { ProgramId = Pk(9), Accounts = [], Data = [] }; + var advance = SystemProgram.AdvanceNonceAccount(nonce, payer); + var table = new AddressLookupTableAccount(Pk(5), [nonce]); + + // Act + var message = MessageV0.Compile(payer, Blockhash(8), [first, advance], [table]); + + // Assert + message.AccountKeys.Should().NotContain(nonce); + message.AddressTableLookups.Should().ContainSingle(); + } + + [Test] + public void MutatingSourceData_DoesNotMutateCompiledMessage() + { + // Arrange + var instruction = new Instruction { ProgramId = Pk(9), Accounts = [], Data = [7] }; + var message = MessageV0.Compile(Pk(1), Blockhash(8), [instruction], []); + + // Act + instruction.Data[0] = 99; + + // Assert + message.Instructions[0].Data.Should().Equal(7); + } } [TestFixture] @@ -153,6 +286,19 @@ public void TruncatedData_ThrowsFormatException() act.Should().Throw(); } + [Test] + public void ImpossibleLookupCount_ThrowsBeforeAllocatingDeclaredArray() + { + // Arrange: replace the zero lookup count with max compact-u16, without adding lookup data. + byte[] data = [.. SerializedV0()[..^1], 0xff, 0xff, 0x03]; + + // Act + Action act = () => MessageV0.Deserialize(data); + + // Assert + act.Should().Throw().WithMessage("*declares 65535 address table lookup(s)*"); + } + [Test] public void ValidCompiledMessage_RoundTrips() { @@ -354,7 +500,20 @@ public void AccountIndexInLookupRange_Deserializes() var message = MessageV0.Deserialize(data); // Assert - message.Instructions[0].AccountIndexes.Should().Equal((byte)0, (byte)3); + message.Instructions[0].AccountIndexes.Should().Equal(0, 3); + } + + [Test] + public void TrailingByte_ThrowsFormatException() + { + // Arrange + byte[] data = [.. SerializedV0(), 0xAA]; + + // Act + Action act = () => MessageV0.Deserialize(data); + + // Assert + act.Should().Throw().WithMessage("*1 trailing byte(s)*"); } } } diff --git a/tests/SolSharp.Programs.Tests/MessageV1Tests.cs b/tests/SolSharp.Programs.Tests/MessageV1Tests.cs new file mode 100644 index 0000000..41f0147 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/MessageV1Tests.cs @@ -0,0 +1,588 @@ +using System.Buffers.Binary; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class MessageV1Tests +{ + private static PublicKey Pk(byte value) => new(Fill(value)); + + private static byte[] Fill(byte value) => [.. Enumerable.Repeat(value, PublicKey.Length)]; + + // solana-sdk ec7a0467e268774b724d55120ad952b518f27d64 + // message/src/versions/v1/message.rs::byte_layout_without_config, plus VersionedMessage's 0x81 prefix. + private static byte[] UpstreamWithoutConfig() + { + var expected = new List { MessageV1.VersionPrefix, 1, 0, 0 }; + expected.AddRange(new byte[sizeof(uint)]); + expected.AddRange(Fill(0xAB)); + expected.Add(1); + expected.Add(2); + expected.AddRange(Fill(1)); + expected.AddRange(Fill(2)); + expected.Add(1); + expected.Add(1); + expected.AddRange([2, 0]); + expected.Add(0); + expected.AddRange([0xDE, 0xAD]); + return [.. expected]; + } + + // solana-sdk ec7a0467e268774b724d55120ad952b518f27d64 + // message/src/versions/v1/message.rs::byte_layout_with_config, plus VersionedMessage's 0x81 prefix. + private static byte[] UpstreamWithConfig() + { + var expected = new List { MessageV1.VersionPrefix, 1, 0, 0 }; + expected.AddRange([7, 0, 0, 0]); + expected.AddRange(Fill(0xBB)); + expected.Add(1); + expected.Add(2); + expected.AddRange(Fill(1)); + expected.AddRange(Fill(2)); + expected.AddRange([8, 7, 6, 5, 4, 3, 2, 1]); + expected.AddRange([0x44, 0x33, 0x22, 0x11]); + expected.Add(1); + expected.Add(0); + expected.AddRange([0, 0]); + return [.. expected]; + } + + private static byte[] WithHeapSize(uint heapSize) + { + var bytes = UpstreamWithoutConfig().ToList(); + bytes[4] = 0b10000; + var encoded = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(encoded, heapSize); + bytes.InsertRange(106, encoded); + return [.. bytes]; + } + + [TestFixture] + public sealed class Serialize + { + [Test] + public void AllConfigFields_RoundTripInMaskOrder() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(2), + Accounts = [AccountMeta.WritableSigner(Pk(1))], + Data = [0xAA] + }; + var config = new TransactionConfigV1 + { + PriorityFee = 1_000, + ComputeUnitLimit = 200_000, + LoadedAccountsDataSizeLimit = 1_000_000, + HeapSize = 65_536 + }; + var message = MessageV1.Compile(Pk(1), new Hash(Fill(0xCC)), [instruction], config); + + // Act + var bytes = message.Serialize(); + var parsed = MessageV1.Deserialize(bytes); + + // Assert + BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(4)).Should().Be(0b11111); + parsed.Config.Should().Be(config); + parsed.Serialize().Should().Equal(bytes); + } + + [Test] + public void SerializeIntoExactSpan_MatchesAllocatingPath() + { + // Arrange + var message = MessageV1.Deserialize(UpstreamWithoutConfig()); + var destination = new byte[message.GetSerializedLength()]; + + // Act + var written = message.Serialize(destination); + + // Assert + written.Should().Be(destination.Length); + destination.Should().Equal(UpstreamWithoutConfig()); + } + + [Test] + public void SerializeIntoShortSpan_Throws() + { + // Arrange + var message = MessageV1.Deserialize(UpstreamWithoutConfig()); + + // Act + Action act = () => message.Serialize(new byte[message.GetSerializedLength() - 1]); + + // Assert + act.Should().Throw().WithParameterName("destination"); + } + } + + [TestFixture] + public sealed class Compile + { + [Test] + public void EmptyConfig_CompilesCanonicalKeyOrderAndHeader() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(2), + Accounts = [AccountMeta.WritableSigner(Pk(1))], + Data = [0xDE, 0xAD] + }; + + // Act + var message = MessageV1.Compile(Pk(1), new Hash(Fill(0xAB)), [instruction]); + + // Assert: unlike the upstream low-level layout fixture, compilation correctly marks the program readonly. + var expected = UpstreamWithoutConfig(); + expected[3] = 1; + message.Serialize().Should().Equal(expected); + message.Config.Should().Be(new TransactionConfigV1()); + } + + [Test] + public void OrdersAndMergesAccountClassesLikeCompiledKeys() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = + [ + AccountMeta.Readonly(Pk(3)), + AccountMeta.ReadonlySigner(Pk(5)), + AccountMeta.Writable(Pk(4)), + AccountMeta.WritableSigner(Pk(6)), + AccountMeta.Readonly(Pk(4)) + ], + Data = [7] + }; + + // Act + var message = MessageV1.Compile(Pk(1), new Hash(Fill(8)), [instruction]); + var decompiled = message.DecompileInstructions(); + + // Assert + message.RequiredSignatures.Should().Be(3); + message.ReadonlySignedAccounts.Should().Be(1); + message.ReadonlyUnsignedAccounts.Should().Be(2); + message.AccountKeys.Should().Equal(Pk(1), Pk(6), Pk(5), Pk(4), Pk(3), Pk(9)); + decompiled.Should().ContainSingle(); + decompiled[0].Accounts.Select(meta => (meta.PublicKey, meta.IsSigner, meta.IsWritable)).Should().Equal( + (Pk(3), false, false), + (Pk(5), true, false), + (Pk(4), false, true), + (Pk(6), true, true), + (Pk(4), false, true)); + } + + [Test] + public void StringAndTypedLifetimeSpecifierOverloadsMatch() + { + // Arrange + var hash = new Hash(Fill(8)); + var instruction = new Instruction { ProgramId = Pk(9), Accounts = [], Data = [1] }; + + // Act + var typed = MessageV1.Compile(Pk(1), hash, [instruction]); + var text = MessageV1.Compile(Pk(1), hash.ToString(), [instruction]); + + // Assert + typed.Serialize().Should().Equal(text.Serialize()); + } + + [Test] + public void MutatingSourceData_DoesNotMutateCompiledMessage() + { + // Arrange + var data = new byte[] { 7 }; + var instruction = new Instruction { ProgramId = Pk(9), Accounts = [], Data = data }; + var message = MessageV1.Compile(Pk(1), new Hash(Fill(8)), [instruction]); + + // Act + data[0] = 99; + + // Assert + message.Instructions[0].Data.Should().Equal(7); + } + + [Test] + public void ThirteenSignatures_Throws() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(250), + Accounts = [.. Enumerable.Range(2, 12).Select(value => AccountMeta.ReadonlySigner(Pk((byte)value)))], + Data = [] + }; + + // Act + Action act = () => MessageV1.Compile(Pk(1), new Hash(Fill(8)), [instruction]); + + // Assert + act.Should().Throw().WithMessage("*at most 12 signatures*"); + } + + [Test] + public void SixtyFiveAddresses_Throws() + { + // Arrange: payer + 63 instruction accounts + program = 65 distinct addresses. + var instruction = new Instruction + { + ProgramId = Pk(250), + Accounts = [.. Enumerable.Range(2, 63).Select(value => AccountMeta.Readonly(Pk((byte)value)))], + Data = [] + }; + + // Act + Action act = () => MessageV1.Compile(Pk(1), new Hash(Fill(8)), [instruction]); + + // Assert + act.Should().Throw().WithMessage("*at most 64 accounts*"); + } + + [Test] + public void SixtyFiveInstructions_Throws() + { + // Arrange + var instruction = new Instruction { ProgramId = Pk(9), Accounts = [], Data = [] }; + var instructions = Enumerable.Repeat(instruction, MessageV1.MaxInstructions + 1).ToArray(); + + // Act + Action act = () => MessageV1.Compile(Pk(1), new Hash(Fill(8)), instructions); + + // Assert + act.Should().Throw().WithMessage("*at most 64 instructions*"); + } + + [Test] + public void TwoHundredFiftySixInstructionAccountSlots_Throws() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = [.. Enumerable.Repeat(AccountMeta.Readonly(Pk(2)), byte.MaxValue + 1)], + Data = [] + }; + + // Act + Action act = () => MessageV1.Compile(Pk(1), new Hash(Fill(8)), [instruction]); + + // Assert + act.Should().Throw().WithMessage("*at most 255 account slots*"); + } + + [Test] + public void SixtyFiveThousandFiveHundredThirtySixDataBytes_Throws() + { + // Arrange + var instruction = new Instruction { ProgramId = Pk(9), Accounts = [], Data = new byte[ushort.MaxValue + 1] }; + + // Act + Action act = () => MessageV1.Compile(Pk(1), new Hash(Fill(8)), [instruction]); + + // Assert + act.Should().Throw().WithMessage("*at most 65535 data bytes*"); + } + + [TestCase(0U)] + [TestCase(32_767U)] + [TestCase(32_769U)] + [TestCase(263_168U)] + public void InvalidHeapSize_Throws(uint heapSize) + { + // Arrange + var instruction = new Instruction { ProgramId = Pk(9), Accounts = [], Data = [] }; + var config = new TransactionConfigV1 { HeapSize = heapSize }; + + // Act + Action act = () => MessageV1.Compile(Pk(1), new Hash(Fill(8)), [instruction], config); + + // Assert + act.Should().Throw().WithMessage("*1024-byte multiple*"); + } + } + + [TestFixture] + public sealed class Validate + { + [Test] + public void CompiledMessage_DoesNotThrow() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = [AccountMeta.Readonly(Pk(2))], + Data = [7] + }; + var message = MessageV1.Compile(Pk(1), new Hash(Fill(8)), [instruction]); + + // Act + Action act = message.Validate; + + // Assert + act.Should().NotThrow(); + } + + [Test] + public void DuplicateAccountIntroducedAfterCompilation_Throws() + { + // Arrange + var instruction = new Instruction { ProgramId = Pk(9), Accounts = [], Data = [7] }; + var message = MessageV1.Compile(Pk(1), new Hash(Fill(8)), [instruction]); + var accountKeys = message.AccountKeys.Should().BeOfType>().Subject; + accountKeys.Add(Pk(1)); + + // Act + Action act = message.Validate; + + // Assert + act.Should().Throw().WithMessage("*duplicate account address*"); + } + + [Test] + public void OutOfRangeInstructionAccountIndexIntroducedAfterCompilation_Throws() + { + // Arrange + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = [AccountMeta.Readonly(Pk(2))], + Data = [7] + }; + var message = MessageV1.Compile(Pk(1), new Hash(Fill(8)), [instruction]); + message.Instructions[0].AccountIndexes[0] = byte.MaxValue; + + // Act + Action act = message.Validate; + + // Assert + act.Should().Throw().WithMessage("*outside*"); + } + } + + [TestFixture] + public sealed class Deserialize + { + [Test] + public void WithoutConfig_RoundTripsPinnedUpstreamLayout() + { + // Arrange + var expected = UpstreamWithoutConfig(); + + // Act + var message = MessageV1.Deserialize(expected); + + // Assert + message.Serialize().Should().Equal(expected); + message.GetSerializedLength().Should().Be(expected.Length); + message.RequiredSignatures.Should().Be(1); + message.ReadonlySignedAccounts.Should().Be(0); + message.ReadonlyUnsignedAccounts.Should().Be(0); + message.LifetimeSpecifier.Should().Be(new Hash(Fill(0xAB))); + message.AccountKeys.Should().Equal(Pk(1), Pk(2)); + message.Config.Should().Be(new TransactionConfigV1()); + message.Instructions.Should().ContainSingle(); + message.Instructions[0].ProgramIdIndex.Should().Be(1); + message.Instructions[0].AccountIndexes.Should().Equal(0); + message.Instructions[0].Data.Should().Equal(Convert.FromHexString("DEAD")); + } + + [Test] + public void WithConfig_RoundTripsPinnedUpstreamLayoutAndEndianOrder() + { + // Arrange + var expected = UpstreamWithConfig(); + + // Act + var message = MessageV1.Deserialize(expected); + + // Assert + message.Serialize().Should().Equal(expected); + message.Config.PriorityFee.Should().Be(0x0102030405060708UL); + message.Config.ComputeUnitLimit.Should().Be(0x11223344U); + message.Config.LoadedAccountsDataSizeLimit.Should().BeNull(); + message.Config.HeapSize.Should().BeNull(); + } + + [TestCase(0b000001U)] + [TestCase(0b000010U)] + [TestCase(0b100000U)] + [TestCase(0x80000000U)] + public void InvalidConfigMask_Throws(uint mask) + { + // Arrange + var bytes = UpstreamWithoutConfig(); + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4), mask); + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*config mask*"); + } + + [TestCase(32_767U)] + [TestCase(32_769U)] + [TestCase(263_168U)] + public void InvalidHeapSize_Throws(uint heapSize) + { + // Arrange + var bytes = WithHeapSize(heapSize); + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*heap size*"); + } + + [Test] + public void WrongVersionPrefix_Throws() + { + // Arrange + var bytes = UpstreamWithoutConfig(); + bytes[0] = MessageV0.VersionPrefix; + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*0x81*"); + } + + [Test] + public void TrailingByte_Throws() + { + // Arrange + byte[] bytes = [.. UpstreamWithoutConfig(), 0xAA]; + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*trailing byte*"); + } + + [TestCase(1, 13)] + [TestCase(40, 65)] + [TestCase(41, 65)] + public void CountAboveV1Maximum_Throws(int offset, byte value) + { + // Arrange + var bytes = UpstreamWithoutConfig(); + bytes[offset] = value; + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw(); + } + + [Test] + public void HeaderWithoutWritableFeePayer_Throws() + { + // Arrange + var bytes = UpstreamWithoutConfig(); + bytes[2] = 1; + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*writable signer*"); + } + + [Test] + public void HeaderRequiringMoreAddressesThanPresent_Throws() + { + // Arrange + var bytes = UpstreamWithoutConfig(); + bytes[3] = 2; + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*cannot satisfy*"); + } + + [Test] + public void DuplicateAddress_Throws() + { + // Arrange + var bytes = UpstreamWithoutConfig(); + bytes.AsSpan(42, PublicKey.Length).CopyTo(bytes.AsSpan(74, PublicKey.Length)); + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*duplicate account address*"); + } + + [TestCase(0)] + [TestCase(2)] + public void InvalidProgramIndex_Throws(byte programIndex) + { + // Arrange + var bytes = UpstreamWithoutConfig(); + bytes[106] = programIndex; + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*program id*"); + } + + [Test] + public void InvalidInstructionAccountIndex_Throws() + { + // Arrange + var bytes = UpstreamWithoutConfig(); + bytes[110] = 2; + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*account index 2*"); + } + + [Test] + public void TruncatedInstructionHeader_Throws() + { + // Arrange + var bytes = UpstreamWithoutConfig()[..109]; + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*instruction header*"); + } + + [Test] + public void DeclaredPayloadLongerThanRemaining_Throws() + { + // Arrange + var bytes = UpstreamWithoutConfig(); + bytes[108] = 3; + + // Act + Action act = () => MessageV1.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*instruction payloads*"); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/PrecompileProgramTests.cs b/tests/SolSharp.Programs.Tests/PrecompileProgramTests.cs new file mode 100644 index 0000000..06a9bfd --- /dev/null +++ b/tests/SolSharp.Programs.Tests/PrecompileProgramTests.cs @@ -0,0 +1,214 @@ +using FluentAssertions; +using NUnit.Framework; +using static SolSharp.Programs.Tests.PrecompileProgramTestHelpers; + +namespace SolSharp.Programs.Tests; + +internal static class PrecompileProgramTestHelpers +{ + internal static string Repeat(byte value, int count) => + string.Concat(Enumerable.Repeat(value.ToString("x2"), count)); + + internal static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); +} + +public static class Ed25519ProgramTests +{ + [TestFixture] + public sealed class CreateInstruction + { + [Test] + public void SelfContainedInstruction_MatchesPinnedRustLayout() + { + // Act + var instruction = Ed25519Program.CreateInstruction( + [0xaa, 0xbb], + Enumerable.Repeat((byte)0x22, Ed25519Program.SignatureLength).ToArray(), + Enumerable.Repeat((byte)0x11, Ed25519Program.PublicKeyLength).ToArray()); + + // Assert + Hex(instruction).Should().Be( + "0100" + + "3000ffff1000ffff70000200ffff" + + Repeat(0x11, 32) + + Repeat(0x22, 64) + + "aabb"); + instruction.Accounts.Should().BeEmpty(); + Ed25519Program.DecodeOffsets(instruction.Data).Should().Equal( + new Ed25519SignatureOffsets(48, ushort.MaxValue, 16, ushort.MaxValue, 112, 2, ushort.MaxValue)); + } + } + + [TestFixture] + public sealed class CreateOffsetsInstruction + { + [Test] + public void OffsetsOnly_RoundTripsExactLittleEndianRecord() + { + // Arrange + var offsets = new Ed25519SignatureOffsets(1, 2, 3, 4, 5, 6, 7); + + // Act + var instruction = Ed25519Program.CreateOffsetsInstruction([offsets]); + + // Assert + Hex(instruction).Should().Be("01000100020003000400050006000700"); + Ed25519Program.DecodeOffsets(instruction.Data).Should().Equal(offsets); + } + + [Test] + public void CountBeyondRuntimeWidth_IsRejected() + { + // Arrange + var tooMany = Enumerable.Repeat(default(Ed25519SignatureOffsets), byte.MaxValue + 1).ToArray(); + + // Act + var act = () => Ed25519Program.CreateOffsetsInstruction(tooMany); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class DecodeOffsets + { + [Test] + public void PaddingIsIgnoredAndZeroCountWithTrailingDataIsRejected() + { + // Arrange + var oneRecordWithIgnoredPadding = Convert.FromHexString("01ff0100020003000400050006000700"); + + // Act + var decoded = Ed25519Program.DecodeOffsets(oneRecordWithIgnoredPadding); + var zeroWithTrailingData = () => Ed25519Program.DecodeOffsets([0, 0, 0]); + + // Assert + decoded.Should().Equal(new Ed25519SignatureOffsets(1, 2, 3, 4, 5, 6, 7)); + zeroWithTrailingData.Should().Throw(); + } + } +} + +public static class Secp256r1ProgramTests +{ + [TestFixture] + public sealed class CreateInstruction + { + [Test] + public void SelfContainedInstruction_MatchesPinnedRustLayout() + { + // Act + var instruction = Secp256r1Program.CreateInstruction( + [0xaa, 0xbb], + Enumerable.Repeat((byte)0x22, Secp256r1Program.SignatureLength).ToArray(), + Enumerable.Repeat((byte)0x11, Secp256r1Program.CompressedPublicKeyLength).ToArray()); + + // Assert + Hex(instruction).Should().Be( + "0100" + + "3100ffff1000ffff71000200ffff" + + Repeat(0x11, 33) + + Repeat(0x22, 64) + + "aabb"); + Secp256r1Program.DecodeOffsets(instruction.Data).Should().Equal( + new Secp256r1SignatureOffsets(49, ushort.MaxValue, 16, ushort.MaxValue, 113, 2, ushort.MaxValue)); + } + } + + [TestFixture] + public sealed class CreateOffsetsInstruction + { + [Test] + public void EmptyAndTooManyRecords_AreRejected() + { + // Arrange + var tooMany = Enumerable.Repeat(default(Secp256r1SignatureOffsets), 9).ToArray(); + + // Act + var createEmpty = () => Secp256r1Program.CreateOffsetsInstruction([]); + var createTooMany = () => Secp256r1Program.CreateOffsetsInstruction(tooMany); + + // Assert + createEmpty.Should().Throw(); + createTooMany.Should().Throw(); + } + } + + [TestFixture] + public sealed class DecodeOffsets + { + [Test] + public void PaddingSemantics_MatchRuntime() + { + // Arrange + var oneRecordWithIgnoredPadding = Convert.FromHexString("01ff0100020003000400050006000000"); + + // Act + var decoded = Secp256r1Program.DecodeOffsets(oneRecordWithIgnoredPadding); + + // Assert + decoded.Should().Equal(new Secp256r1SignatureOffsets(1, 2, 3, 4, 5, 6, 0)); + } + } +} + +public static class Secp256k1ProgramTests +{ + [TestFixture] + public sealed class CreateInstruction + { + [Test] + public void SelfContainedInstruction_MatchesPinnedRustLayout() + { + // Act + var instruction = Secp256k1Program.CreateInstruction( + [0xaa, 0xbb], + Enumerable.Repeat((byte)0x22, Secp256k1Program.SignatureLength).ToArray(), + 1, + Enumerable.Repeat((byte)0x11, Secp256k1Program.EthereumAddressLength).ToArray()); + + // Assert + Hex(instruction).Should().Be( + "01" + + "2000000c00006100020000" + + Repeat(0x11, 20) + + Repeat(0x22, 64) + + "01aabb"); + Secp256k1Program.DecodeOffsets(instruction.Data).Should().Equal( + new Secp256k1SignatureOffsets(32, 0, 12, 0, 97, 2, 0)); + } + } + + [TestFixture] + public sealed class CreateOffsetsInstruction + { + [Test] + public void OffsetsOnly_RoundTripsMixedWidthRecord() + { + // Arrange + var offsets = new Secp256k1SignatureOffsets(1, 2, 3, 4, 5, 6, 7); + + // Act + var instruction = Secp256k1Program.CreateOffsetsInstruction([offsets]); + + // Assert + Hex(instruction).Should().Be("010100020300040500060007"); + Secp256k1Program.DecodeOffsets(instruction.Data).Should().Equal(offsets); + } + } + + [TestFixture] + public sealed class DecodeOffsets + { + [Test] + public void ZeroCountWithTrailingData_IsRejectedLikeTheRuntime() + { + // Act + var act = () => Secp256k1Program.DecodeOffsets([0, 0]); + + // Assert + act.Should().Throw(); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/ProgramDerivedAddressTests.cs b/tests/SolSharp.Programs.Tests/ProgramDerivedAddressTests.cs index 2e31905..fc5dfdc 100644 --- a/tests/SolSharp.Programs.Tests/ProgramDerivedAddressTests.cs +++ b/tests/SolSharp.Programs.Tests/ProgramDerivedAddressTests.cs @@ -8,6 +8,77 @@ public static class ProgramDerivedAddressTests { private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + [TestFixture] + public sealed class CreateWithSeed + { + [Test] + public void UpstreamKnownVector_MatchesSolanaSdk() + { + // Act + var address = ProgramDerivedAddress.CreateWithSeed( + default, + "limber chicken: 4/45", + default); + + // Assert + address.Should().Be(PublicKey.Parse("9h1HyLCW5dZnBVap8C5egQ9Z6pHyjsh5MNy83iPqqRuq")); + } + + [Test] + public void SeedLimit_IsMeasuredInUtf8Bytes() + { + // Arrange: each U+10FFFF scalar is four UTF-8 bytes, matching solana-sdk's boundary vector. + var maxLengthSeed = string.Concat(Enumerable.Repeat("\U0010FFFF", 8)); + + // Act + Action accepted = () => ProgramDerivedAddress.CreateWithSeed(Key(1), maxLengthSeed, Key(2)); + Action rejected = () => ProgramDerivedAddress.CreateWithSeed(Key(1), "x" + maxLengthSeed, Key(2)); + + // Assert + accepted.Should().NotThrow(); + rejected.Should().Throw() + .Which.ParamName.Should().Be("seed"); + } + + [Test] + public void OwnerEndingInReservedMarker_Throws() + { + // Arrange + var ownerBytes = new byte[PublicKey.Length]; + "ProgramDerivedAddress"u8.CopyTo(ownerBytes.AsSpan(PublicKey.Length - 21)); + var owner = new PublicKey(ownerBytes); + + // Act + Action act = () => ProgramDerivedAddress.CreateWithSeed(Key(1), "seed", owner); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be("owner"); + } + + [Test] + public void NullSeed_Throws() + { + // Act + Action act = () => ProgramDerivedAddress.CreateWithSeed(Key(1), null!, Key(2)); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be("seed"); + } + + [Test] + public void InvalidUtf16Seed_Throws() + { + // Act + Action act = () => ProgramDerivedAddress.CreateWithSeed(Key(1), "\uD800", Key(2)); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be("seed"); + } + } + [TestFixture] public sealed class FindProgramAddress { @@ -29,7 +100,7 @@ public void MatchesSolanaSdk() [Test] public void SixteenSeeds_Throws() { - // Arrange: 16 caller seeds leave no slot for the bump, so every derivation attempt exceeds MaxSeeds. + // Arrange: 16 caller seeds leave no slot for the bump. var seeds = Enumerable.Range(0, ProgramDerivedAddress.MaxSeeds).Select(i => new byte[] { (byte)i }).ToArray(); // Act @@ -38,6 +109,20 @@ public void SixteenSeeds_Throws() // Assert act.Should().Throw(); } + + [Test] + public void NullSeed_ThrowsDocumentedArgumentNullException() + { + // Arrange + byte[][] seeds = [null!]; + + // Act + Action act = () => ProgramDerivedAddress.FindProgramAddress(seeds, Key(9)); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be(nameof(seeds)); + } } [TestFixture] @@ -82,5 +167,19 @@ public void ExactlyMaxSeeds_IsAccepted() // Assert act.Should().NotThrow(); } + + [Test] + public void NullSeed_ThrowsDocumentedArgumentNullException() + { + // Arrange + byte[][] seeds = [null!]; + + // Act + Action act = () => ProgramDerivedAddress.TryCreateProgramAddress(seeds, Key(9), out _); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be(nameof(seeds)); + } } } diff --git a/tests/SolSharp.Programs.Tests/StakeProgramParityTests.cs b/tests/SolSharp.Programs.Tests/StakeProgramParityTests.cs new file mode 100644 index 0000000..71704c6 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/StakeProgramParityTests.cs @@ -0,0 +1,482 @@ +using System.Buffers.Binary; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; +using static SolSharp.Programs.Tests.StakeProgramTestHelpers; + +namespace SolSharp.Programs.Tests; + +internal static class StakeProgramTestHelpers +{ + internal static PublicKey Pk(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + internal static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + internal static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; +} + +public static class StakeProgramTests +{ + [TestFixture] + public sealed class CreateAccount + { + [Test] + public void ComposesSystemCreateAndInitialize() + { + // Arrange + const ulong lamports = 123; + var authorized = new StakeAuthorized(Pk(3), Pk(4)); + var lockup = new StakeLockup(-2, 7, Pk(5)); + var expectedCreate = SystemProgram.CreateAccount( + Pk(1), Pk(2), lamports, StakeProgram.AccountDataLength, StakeProgram.ProgramId); + var expectedInitialize = StakeProgram.Initialize(Pk(2), authorized, lockup); + + // Act + var instructions = StakeProgram.CreateAccount(Pk(1), Pk(2), authorized, lockup, lamports); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Should().BeEquivalentTo(expectedCreate); + instructions[1].Should().BeEquivalentTo(expectedInitialize); + } + } + + [TestFixture] + public sealed class CreateAccountChecked + { + [Test] + public void ComposesSystemCreateAndCheckedInitialize() + { + // Arrange + const ulong lamports = 123; + var authorized = new StakeAuthorized(Pk(3), Pk(4)); + var expectedCreate = SystemProgram.CreateAccount( + Pk(1), Pk(2), lamports, StakeProgram.AccountDataLength, StakeProgram.ProgramId); + var expectedInitialize = StakeProgram.InitializeChecked(Pk(2), authorized); + + // Act + var instructions = StakeProgram.CreateAccountChecked(Pk(1), Pk(2), authorized, lamports); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Should().BeEquivalentTo(expectedCreate); + instructions[1].Should().BeEquivalentTo(expectedInitialize); + } + } + + [TestFixture] + public sealed class CreateAccountWithSeed + { + [Test] + public void ComposesSystemCreateWithSeedAndInitialize() + { + // Arrange + const ulong lamports = 123; + const string seed = "stake-seed"; + var authorized = new StakeAuthorized(Pk(4), Pk(5)); + var lockup = new StakeLockup(-2, 7, Pk(6)); + var expectedCreate = SystemProgram.CreateAccountWithSeed( + Pk(1), + Pk(2), + Pk(3), + seed, + lamports, + StakeProgram.AccountDataLength, + StakeProgram.ProgramId); + var expectedInitialize = StakeProgram.Initialize(Pk(2), authorized, lockup); + + // Act + var instructions = StakeProgram.CreateAccountWithSeed( + Pk(1), Pk(2), Pk(3), seed, authorized, lockup, lamports); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Should().BeEquivalentTo(expectedCreate); + instructions[1].Should().BeEquivalentTo(expectedInitialize); + } + } + + [TestFixture] + public sealed class CreateAccountWithSeedChecked + { + [Test] + public void ComposesSystemCreateWithSeedAndCheckedInitialize() + { + // Arrange + const ulong lamports = 123; + const string seed = "stake-seed"; + var authorized = new StakeAuthorized(Pk(4), Pk(5)); + var expectedCreate = SystemProgram.CreateAccountWithSeed( + Pk(1), + Pk(2), + Pk(3), + seed, + lamports, + StakeProgram.AccountDataLength, + StakeProgram.ProgramId); + var expectedInitialize = StakeProgram.InitializeChecked(Pk(2), authorized); + + // Act + var instructions = StakeProgram.CreateAccountWithSeedChecked( + Pk(1), Pk(2), Pk(3), seed, authorized, lamports); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Should().BeEquivalentTo(expectedCreate); + instructions[1].Should().BeEquivalentTo(expectedInitialize); + } + } + + [TestFixture] + public sealed class CreateAccountAndDelegateStake + { + [Test] + public void AppendsDelegateInstructionUsingStakerAuthority() + { + // Arrange + const ulong lamports = 123; + var authorized = new StakeAuthorized(Pk(3), Pk(4)); + var lockup = new StakeLockup(-2, 7, Pk(5)); + var expectedCreate = StakeProgram.CreateAccount(Pk(1), Pk(2), authorized, lockup, lamports); + var expectedDelegate = StakeProgram.DelegateStake(Pk(2), authorized.Staker, Pk(6)); + + // Act + var instructions = StakeProgram.CreateAccountAndDelegateStake( + Pk(1), Pk(2), Pk(6), authorized, lockup, lamports); + + // Assert + instructions.Should().HaveCount(3); + instructions[0].Should().BeEquivalentTo(expectedCreate[0]); + instructions[1].Should().BeEquivalentTo(expectedCreate[1]); + instructions[2].Should().BeEquivalentTo(expectedDelegate); + } + } + + [TestFixture] + public sealed class CreateAccountWithSeedAndDelegateStake + { + [Test] + public void AppendsDelegateInstructionUsingStakerAuthority() + { + // Arrange + const ulong lamports = 123; + const string seed = "stake-seed"; + var authorized = new StakeAuthorized(Pk(4), Pk(5)); + var lockup = new StakeLockup(-2, 7, Pk(6)); + var expectedCreate = StakeProgram.CreateAccountWithSeed( + Pk(1), Pk(2), Pk(3), seed, authorized, lockup, lamports); + var expectedDelegate = StakeProgram.DelegateStake(Pk(2), authorized.Staker, Pk(7)); + + // Act + var instructions = StakeProgram.CreateAccountWithSeedAndDelegateStake( + Pk(1), Pk(2), Pk(3), seed, Pk(7), authorized, lockup, lamports); + + // Assert + instructions.Should().HaveCount(3); + instructions[0].Should().BeEquivalentTo(expectedCreate[0]); + instructions[1].Should().BeEquivalentTo(expectedCreate[1]); + instructions[2].Should().BeEquivalentTo(expectedDelegate); + } + } + + [TestFixture] + public sealed class SplitStake + { + [Test] + public void ComposesAllocateAssignAndNativeSplit() + { + // Arrange + const ulong lamports = 123; + var expectedAllocate = SystemProgram.Allocate(Pk(3), StakeProgram.AccountDataLength); + var expectedAssign = SystemProgram.Assign(Pk(3), StakeProgram.ProgramId); + var expectedSplit = StakeProgram.SplitStakeInstruction(Pk(1), Pk(2), lamports, Pk(3)); + + // Act + var instructions = StakeProgram.SplitStake(Pk(1), Pk(2), lamports, Pk(3)); + + // Assert + instructions.Should().HaveCount(3); + instructions[0].Should().BeEquivalentTo(expectedAllocate); + instructions[1].Should().BeEquivalentTo(expectedAssign); + instructions[2].Should().BeEquivalentTo(expectedSplit); + } + } + + [TestFixture] + public sealed class SplitStakeWithSeed + { + [Test] + public void ComposesAllocateWithSeedAndNativeSplit() + { + // Arrange + const ulong lamports = 123; + const string seed = "split-seed"; + var expectedAllocate = SystemProgram.AllocateWithSeed( + Pk(3), Pk(4), seed, StakeProgram.AccountDataLength, StakeProgram.ProgramId); + var expectedSplit = StakeProgram.SplitStakeInstruction(Pk(1), Pk(2), lamports, Pk(3)); + + // Act + var instructions = StakeProgram.SplitStakeWithSeed(Pk(1), Pk(2), lamports, Pk(3), Pk(4), seed); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Should().BeEquivalentTo(expectedAllocate); + instructions[1].Should().BeEquivalentTo(expectedSplit); + } + } + + [TestFixture] + public sealed class Initialize + { + [Test] + public void MatchesStakeInterfaceBincodeLayout() + { + // Act + var instruction = StakeProgram.Initialize( + Pk(9), + new StakeAuthorized(Pk(1), Pk(2)), + new StakeLockup(-2, 3, Pk(4))); + + // Assert + var expected = + "00000000" + + string.Concat(Enumerable.Repeat("01", 32)) + + string.Concat(Enumerable.Repeat("02", 32)) + + "feffffffffffffff0300000000000000" + + string.Concat(Enumerable.Repeat("04", 32)); + Hex(instruction).Should().Be(expected); + instruction.Accounts.Select(account => account.PublicKey).Should().Equal( + Pk(9), + PublicKey.Parse("SysvarRent111111111111111111111111111111111")); + } + } + + [TestFixture] + public sealed class Authorize + { + [Test] + public void MatchesPinnedDiscriminatorAndFieldOrder() => + Hex(StakeProgram.Authorize(Pk(1), Pk(2), Pk(3), StakeAuthorityType.Withdrawer)) + .Should().Be("01000000" + string.Concat(Enumerable.Repeat("03", 32)) + "01000000"); + } + + [TestFixture] + public sealed class Withdraw + { + [Test] + public void MatchesPinnedWireAndOptionalCustodianOrdering() + { + // Act + var instruction = StakeProgram.Withdraw( + Pk(1), Pk(2), Pk(3), 0x0102030405060708, Pk(4)); + + // Assert + Hex(instruction).Should().Be("040000000807060504030201"); + Metas(instruction).Should().Equal( + (Pk(1), false, true), + (Pk(3), false, true), + (PublicKey.Parse(Sysvars.Clock), false, false), + (PublicKey.Parse(Sysvars.StakeHistory), false, false), + (Pk(2), true, false), + (Pk(4), true, false)); + } + } + + [TestFixture] + public sealed class SplitStakeInstruction + { + [Test] + public void MatchesPinnedDiscriminatorAndFieldOrder() => + Hex(StakeProgram.SplitStakeInstruction(Pk(1), Pk(2), 0x0102030405060708, Pk(3))) + .Should().Be("030000000807060504030201"); + } + + [TestFixture] + public sealed class SetLockup + { + [Test] + public void MatchesPinnedDiscriminatorAndFieldOrder() => + Hex(StakeProgram.SetLockup(Pk(1), new StakeLockupArguments(-2, null, Pk(4)), Pk(2))) + .Should().Be( + "0600000001feffffffffffffff00" + + "01" + string.Concat(Enumerable.Repeat("04", 32))); + } + + [TestFixture] + public sealed class AuthorizeWithSeed + { + [Test] + public void MatchesPinnedDiscriminatorAndFieldOrder() => + Hex(StakeProgram.AuthorizeWithSeed( + Pk(1), + Pk(2), + "ab", + Pk(4), + Pk(3), + StakeAuthorityType.Staker)) + .Should().Be( + "08000000" + string.Concat(Enumerable.Repeat("03", 32)) + + "0000000002000000000000006162" + string.Concat(Enumerable.Repeat("04", 32))); + } + + [TestFixture] + public sealed class AuthorizeCheckedWithSeed + { + [Test] + public void MatchesPinnedDiscriminatorAndFieldOrder() => + Hex(StakeProgram.AuthorizeCheckedWithSeed( + Pk(1), + Pk(2), + "ab", + Pk(4), + Pk(3), + StakeAuthorityType.Withdrawer)) + .Should().Be( + "0b0000000100000002000000000000006162" + string.Concat(Enumerable.Repeat("04", 32))); + } + + [TestFixture] + public sealed class SetLockupChecked + { + [Test] + public void MatchesPinnedDiscriminatorAndFieldOrder() => + Hex(StakeProgram.SetLockupChecked(Pk(1), new StakeLockupArguments(null, 7, Pk(4)), Pk(2))) + .Should().Be("0c00000000010700000000000000"); + } + + [TestFixture] + public sealed class MoveStake + { + [Test] + public void MatchesPinnedDiscriminatorAndFieldOrder() => + Hex(StakeProgram.MoveStake(Pk(1), Pk(2), Pk(3), 9)).Should().Be("100000000900000000000000"); + } + + [TestFixture] + public sealed class MoveLamports + { + [Test] + public void MatchesPinnedDiscriminatorAndFieldOrder() => + Hex(StakeProgram.MoveLamports(Pk(1), Pk(2), Pk(3), 9)).Should().Be("110000000900000000000000"); + } + + [TestFixture] + public sealed class DelegateStake + { + [Test] + public void UsesPinnedStableDiscriminator() => + Hex(StakeProgram.DelegateStake(Pk(1), Pk(2), Pk(3))).Should().Be("02000000"); + } + + [TestFixture] + public sealed class Deactivate + { + [Test] + public void UsesPinnedStableDiscriminator() => + Hex(StakeProgram.Deactivate(Pk(1), Pk(2))).Should().Be("05000000"); + } + + [TestFixture] + public sealed class Merge + { + [Test] + public void UsesPinnedStableDiscriminator() => + Hex(StakeProgram.Merge(Pk(1), Pk(2), Pk(3))).Should().Be("07000000"); + } + + [TestFixture] + public sealed class InitializeChecked + { + [Test] + public void UsesPinnedStableDiscriminator() => + Hex(StakeProgram.InitializeChecked(Pk(1), new StakeAuthorized(Pk(2), Pk(3)))) + .Should().Be("09000000"); + } + + [TestFixture] + public sealed class AuthorizeChecked + { + [Test] + public void UsesPinnedStableDiscriminator() => + Hex(StakeProgram.AuthorizeChecked(Pk(1), Pk(2), Pk(3), StakeAuthorityType.Staker)) + .Should().Be("0a00000000000000"); + } + + [TestFixture] + public sealed class GetMinimumDelegation + { + [Test] + public void UsesPinnedStableDiscriminator() => + Hex(StakeProgram.GetMinimumDelegation()).Should().Be("0d000000"); + } + + [TestFixture] + public sealed class DeactivateDelinquent + { + [Test] + public void UsesPinnedStableDiscriminator() => + Hex(StakeProgram.DeactivateDelinquent(Pk(1), Pk(2), Pk(3))).Should().Be("0e000000"); + } +} + +public static class StakeAuthorizedTests +{ + [TestFixture] + public sealed class ForSingleAuthority + { + [Test] + public void AssignsSameKeyToBothRoles() + { + // Act + var authorized = StakeAuthorized.ForSingleAuthority(Pk(1)); + + // Assert + authorized.Staker.Should().Be(Pk(1)); + authorized.Withdrawer.Should().Be(Pk(1)); + } + } +} + +public static class StakeAccountStateTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void DelegatedState_DecodesExactStakeStateV2Offsets() + { + // Arrange + var data = new byte[StakeAccountState.AccountDataLength]; + BinaryPrimitives.WriteUInt32LittleEndian(data, 2); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(4), 11); + Pk(1).CopyTo(data.AsSpan(12)); + Pk(2).CopyTo(data.AsSpan(44)); + BinaryPrimitives.WriteInt64LittleEndian(data.AsSpan(76), -3); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(84), 5); + Pk(3).CopyTo(data.AsSpan(92)); + Pk(4).CopyTo(data.AsSpan(124)); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(156), 13); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(164), 17); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(172), ulong.MaxValue); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(180), 19); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(188), 23); + data[196] = 1; + + // Act + var state = StakeAccountState.Parse(data); + + // Assert + state.Kind.Should().Be(StakeAccountStateKind.Stake); + state.Metadata.Should().Be(new StakeAccountMetadata( + 11, + new StakeAuthorized(Pk(1), Pk(2)), + new StakeLockup(-3, 5, Pk(3)))); + state.Stake.Should().Be(new StakeDelegatedData( + new StakeDelegation(Pk(4), 13, 17, ulong.MaxValue, 19), + 23)); + state.StakeFlags.Should().Be(1); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/SystemProgramTests.cs b/tests/SolSharp.Programs.Tests/SystemProgramTests.cs index cc97538..4916075 100644 --- a/tests/SolSharp.Programs.Tests/SystemProgramTests.cs +++ b/tests/SolSharp.Programs.Tests/SystemProgramTests.cs @@ -38,6 +38,38 @@ public void MatchesSolanaSdk() } } + [TestFixture] + public sealed class TransferMany + { + [Test] + public void PreservesOrderAndUsesCanonicalTransferWire() + { + // Act + var instructions = SystemProgram.TransferMany(Key(1), (Key(2), 7), (Key(3), 9)); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Data.Should().Equal(Hex("020000000700000000000000")); + instructions[1].Data.Should().Equal(Hex("020000000900000000000000")); + Metas(instructions[0]).Should().Equal((Key(1), true, true), (Key(2), false, true)); + Metas(instructions[1]).Should().Equal((Key(1), true, true), (Key(3), false, true)); + } + + [Test] + public void EmptyInput_ReturnsEmptyArray() + => SystemProgram.TransferMany(Key(1)).Should().BeEmpty(); + + [Test] + public void NullInput_Throws() + { + // Act + Action act = () => _ = SystemProgram.TransferMany(Key(1), null!); + + // Assert + act.Should().Throw().WithParameterName("transfers"); + } + } + [TestFixture] public sealed class CreateAccount { @@ -67,10 +99,53 @@ public void MatchesSolanaSdk() } } + [TestFixture] + public sealed class CreateAccountAllowPrefund + { + // Exact layout from the pinned generated System client: discriminator 13 followed by + // lamports, space, and owner. The payer account is optional and follows the new account. + [Test] + public void MatchesGeneratedSystemClientWithoutPayer() + { + // Act + var instruction = SystemProgram.CreateAccountAllowPrefund(Key(2), 165, Key(9)); + + // Assert + instruction.Data.Should().Equal(Hex( + "0d0000000000000000000000a500000000000000" + + "0909090909090909090909090909090909090909090909090909090909090909")); + Metas(instruction).Should().Equal((Key(2), true, true)); + } + + [Test] + public void MatchesGeneratedSystemClientWithPayer() + { + // Act + var instruction = SystemProgram.CreateAccountAllowPrefund(Key(2), 165, Key(9), 42, Key(1)); + + // Assert + instruction.Data.Should().Equal(Hex( + "0d0000002a00000000000000a500000000000000" + + "0909090909090909090909090909090909090909090909090909090909090909")); + Metas(instruction).Should().Equal((Key(2), true, true), (Key(1), true, true)); + } + + [Test] + public void AdditionalLamportsWithoutPayer_ThrowsArgumentException() + { + // Act + Action act = () => _ = SystemProgram.CreateAccountAllowPrefund(Key(2), 165, Key(9), lamports: 1); + + // Assert + act.Should().Throw().WithParameterName("payer"); + } + } + private static (PublicKey, bool, bool)[] Metas(Instruction instruction) => [.. instruction.Accounts.Select(a => (a.PublicKey, a.IsSigner, a.IsWritable))]; private static PublicKey RecentBlockhashes => PublicKey.Parse(Sysvars.RecentBlockhashes); + private static PublicKey Rent => PublicKey.Parse(Sysvars.Rent); [TestFixture] @@ -191,6 +266,23 @@ public void MatchesSolanaSdk() } } + [TestFixture] + public sealed class UpgradeNonceAccount + { + // Reference from the pinned generated Rust client: discriminator 12 and one writable non-signer. + [Test] + public void MatchesGeneratedSolanaClient() + { + // Act + var instruction = SystemProgram.UpgradeNonceAccount(Key(2)); + + // Assert + instruction.ProgramId.Should().Be(PublicKey.Parse(SolanaProgramIds.SystemProgram)); + instruction.Data.Should().Equal(Hex("0c000000")); + Metas(instruction).Should().Equal((Key(2), false, true)); + } + } + [TestFixture] public sealed class AllocateWithSeed { @@ -264,4 +356,98 @@ public void MatchesSolanaSdk() Metas(instructions[1]).Should().Equal((Key(2), false, true), (RecentBlockhashes, false, false), (Rent, false, false)); } } + + [TestFixture] + public sealed class CreateNonceAccountWithSeed + { + // Exact bincode layout from pinned solana-system-interface create_nonce_account_with_seed. + [Test] + public void MatchesPinnedSystemInterface() + { + // Act + var instructions = SystemProgram.CreateNonceAccountWithSeed( + Key(1), Key(8), Key(2), "hello", Key(3), 1_447_680); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Data.Should().Equal(Hex( + "03000000" + + "0202020202020202020202020202020202020202020202020202020202020202" + + "050000000000000068656c6c6f00171600000000005000000000000000" + + "0000000000000000000000000000000000000000000000000000000000000000")); + Metas(instructions[0]).Should().Equal( + (Key(1), true, true), + (Key(8), false, true), + (Key(2), true, false)); + instructions[1].Data.Should().Equal(Hex( + "060000000303030303030303030303030303030303030303030303030303030303030303")); + Metas(instructions[1]).Should().Equal( + (Key(8), false, true), + (RecentBlockhashes, false, false), + (Rent, false, false)); + } + } + + [TestFixture] + public sealed class WithSeedValidation + { + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void SeedLongerThanThirtyTwoUtf8Bytes_Throws(int operation) + { + // Arrange + var seed = new string('a', 33); + Action act = operation switch + { + 0 => () => _ = SystemProgram.CreateAccountWithSeed(Key(1), Key(2), Key(3), seed, 1, 1, Key(9)), + 1 => () => _ = SystemProgram.AllocateWithSeed(Key(2), Key(3), seed, 1, Key(9)), + 2 => () => _ = SystemProgram.AssignWithSeed(Key(2), Key(3), seed, Key(9)), + _ => () => _ = SystemProgram.TransferWithSeed(Key(2), Key(3), seed, Key(9), Key(4), 1) + }; + + // Act & Assert + act.Should().Throw().WithMessage("*at most 32 bytes*33*"); + } + + [Test] + public void ThirtyTwoUtf8Bytes_IsAccepted() + { + // Act + Action act = () => _ = SystemProgram.CreateAccountWithSeed( + Key(1), Key(2), Key(3), new string('a', 32), 1, 1, Key(9)); + + // Assert + act.Should().NotThrow(); + } + + [Test] + public void LimitIsMeasuredInUtf8Bytes_NotCharacters() + { + // Arrange: seventeen two-byte characters occupy 34 bytes. + var seed = new string('\u00E9', 17); + + // Act + Action act = () => _ = SystemProgram.AssignWithSeed(Key(2), Key(3), seed, Key(9)); + + // Assert + act.Should().Throw().WithMessage("*34*"); + } + + [Test] + public void UnpairedSurrogate_ThrowsInsteadOfEncodingReplacementCharacter() + { + // Arrange + const string seed = "\uD800"; + + // Act + Action act = () => _ = SystemProgram.AssignWithSeed(Key(2), Key(3), seed, Key(9)); + + // Assert + act.Should().Throw() + .WithParameterName(nameof(seed)) + .WithMessage("*valid Unicode*"); + } + } } diff --git a/tests/SolSharp.Programs.Tests/Token2022AccountExtensionTests.cs b/tests/SolSharp.Programs.Tests/Token2022AccountExtensionTests.cs new file mode 100644 index 0000000..017e8d9 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/Token2022AccountExtensionTests.cs @@ -0,0 +1,113 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class Token2022AccountExtensionTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + private static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + private static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; + + [TestFixture] + public sealed class InitializeDefaultAccountState + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = Token2022Program.InitializeDefaultAccountState(Key(1), DefaultTokenAccountState.Frozen); + + // Assert + Hex(instruction).Should().Be("1c0002"); + Metas(instruction).Should().Equal((Key(1), false, true)); + } + + [Test] + public void UndefinedStateIsRejected() + { + // Act + Action act = () => _ = Token2022Program.InitializeDefaultAccountState( + Key(1), (DefaultTokenAccountState)255); + + // Assert + act.Should().Throw().WithParameterName("state"); + } + } + + [TestFixture] + public sealed class UpdateDefaultAccountState + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = Token2022Program.UpdateDefaultAccountState( + Key(1), + Key(2), + DefaultTokenAccountState.Initialized, + [Key(3), Key(4)]); + + // Assert + Hex(instruction).Should().Be("1c0101"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(3), true, false), + (Key(4), true, false)); + } + } + + [TestFixture] + public sealed class EnableRequiredTransferMemos + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = Token2022Program.EnableRequiredTransferMemos(Key(1), Key(2)); + + // Assert + Hex(instruction).Should().Be("1e00"); + Metas(instruction).Should().Equal((Key(1), false, true), (Key(2), true, false)); + } + } + + [TestFixture] + public sealed class DisableRequiredTransferMemos + { + [Test] + public void MatchesPinnedInterface() => + Hex(Token2022Program.DisableRequiredTransferMemos(Key(1), Key(2))).Should().Be("1e01"); + } + + [TestFixture] + public sealed class EnableCpiGuard + { + [Test] + public void MatchesPinnedInterface() => + Hex(Token2022Program.EnableCpiGuard(Key(1), Key(2))).Should().Be("2200"); + } + + [TestFixture] + public sealed class DisableCpiGuard + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = Token2022Program.DisableCpiGuard(Key(1), Key(2), [Key(3)]); + + // Assert + Hex(instruction).Should().Be("2201"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(3), true, false)); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/Token2022ConfidentialInstructionTests.cs b/tests/SolSharp.Programs.Tests/Token2022ConfidentialInstructionTests.cs new file mode 100644 index 0000000..b8e9368 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/Token2022ConfidentialInstructionTests.cs @@ -0,0 +1,509 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; +using static SolSharp.Programs.Tests.Token2022ConfidentialTestHelpers; + +namespace SolSharp.Programs.Tests; + +internal static class Token2022ConfidentialTestHelpers +{ + internal static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + internal static byte[] Pod(byte value, int length) => [.. Enumerable.Repeat(value, length)]; + + internal static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + internal static string RepeatedHex(byte value, int length) + => string.Concat(Enumerable.Repeat(value.ToString("x2"), length)); + + internal static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; +} + +public static class Token2022ConfidentialInstructionTests +{ + [TestFixture] + public sealed class InitializeConfidentialTransferMint + { + [Test] + public void MatchesPinnedPodLayout() + { + // Act + var instruction = Token2022Program.InitializeConfidentialTransferMint( + Key(1), + Key(2), + autoApproveNewAccounts: true, + Pod(3, Token2022Program.ElGamalPublicKeyLength)); + + // Assert + Hex(instruction).Should().Be( + "1b00" + RepeatedHex(2, PublicKey.Length) + "01" + + RepeatedHex(3, Token2022Program.ElGamalPublicKeyLength)); + } + } + + [TestFixture] + public sealed class ConfigureConfidentialTransferAccount + { + [Test] + public void MatchesPinnedPodLayoutAndAccountOrdering() + { + // Act + var instruction = Token2022Program.ConfigureConfidentialTransferAccount( + Key(4), + Key(1), + Pod(5, Token2022Program.DecryptableBalanceLength), + 100, + Key(6), + ConfidentialProofLocation.AtInstructionOffset(-2)); + + // Assert + Hex(instruction).Should().Be( + "1b02" + RepeatedHex(5, Token2022Program.DecryptableBalanceLength) + + "6400000000000000fe"); + Metas(instruction).Should().Equal( + (Key(4), false, true), + (Key(1), false, false), + (PublicKey.Parse(Sysvars.Instructions), false, false), + (Key(6), true, false)); + } + } + + [TestFixture] + public sealed class TransferConfidentialTokens + { + [Test] + public void UsesOneSysvarThenContextAccountsInProofOrder() + { + // Act + var instruction = Token2022Program.TransferConfidentialTokens( + Key(1), + Key(2), + Key(3), + Pod(0xaa, Token2022Program.DecryptableBalanceLength), + Pod(0xbb, Token2022Program.ElGamalCiphertextLength), + Pod(0xcc, Token2022Program.ElGamalCiphertextLength), + Key(4), + ConfidentialProofLocation.AtInstructionOffset(1), + ConfidentialProofLocation.AtContextState(Key(5)), + ConfidentialProofLocation.AtInstructionOffset(-2)); + + // Assert + instruction.Data.Should().HaveCount(169); + Hex(instruction).Should().Be( + "1b07" + + string.Concat(Enumerable.Repeat("aa", Token2022Program.DecryptableBalanceLength)) + + string.Concat(Enumerable.Repeat("bb", Token2022Program.ElGamalCiphertextLength)) + + string.Concat(Enumerable.Repeat("cc", Token2022Program.ElGamalCiphertextLength)) + + "0100fe"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(3), false, true), + (PublicKey.Parse(Sysvars.Instructions), false, false), + (Key(5), false, false), + (Key(4), true, false)); + } + } + + [TestFixture] + public sealed class TransferConfidentialTokensWithFee + { + [Test] + public void UsesPinnedInnerTagAndProofOffsets() + { + // Act + var instruction = Token2022Program.TransferConfidentialTokensWithFee( + Key(1), + Key(2), + Key(3), + Pod(4, Token2022Program.DecryptableBalanceLength), + Pod(5, Token2022Program.ElGamalCiphertextLength), + Pod(6, Token2022Program.ElGamalCiphertextLength), + Key(7), + ConfidentialProofLocation.AtInstructionOffset(1), + ConfidentialProofLocation.AtInstructionOffset(2), + ConfidentialProofLocation.AtInstructionOffset(3), + ConfidentialProofLocation.AtInstructionOffset(4), + ConfidentialProofLocation.AtInstructionOffset(5)); + + // Assert + instruction.Data.Should().HaveCount(171); + instruction.Data.Take(2).Should().Equal(27, 13); + instruction.Data.TakeLast(5).Should().Equal(1, 2, 3, 4, 5); + } + } + + [TestFixture] + public sealed class ConfigureConfidentialTransferAccountWithRegistry + { + [Test] + public void UsesPinnedInnerTagAndAccountOrdering() + { + // Act + var instruction = Token2022Program.ConfigureConfidentialTransferAccountWithRegistry( + Key(1), Key(2), Key(3), Key(4)); + + // Assert + instruction.Data.Should().Equal(27, 14); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(3), false, false), + (Key(4), true, true), + (PublicKey.Parse(SolanaProgramIds.SystemProgram), false, false)); + } + } + + [TestFixture] + public sealed class ApproveConfidentialTransferAccount + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.ApproveConfidentialTransferAccount(Key(1), Key(2), Key(3)).Data + .Should().Equal(27, 3); + } + + [TestFixture] + public sealed class DepositConfidentialTokens + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.DepositConfidentialTokens(Key(1), Key(2), 3, 4, Key(5)).Data.Take(2) + .Should().Equal(27, 5); + } + + [TestFixture] + public sealed class ApplyConfidentialPendingBalance + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.ApplyConfidentialPendingBalance( + Key(1), 2, Pod(3, Token2022Program.DecryptableBalanceLength), Key(4)).Data.Take(2) + .Should().Equal(27, 8); + } + + [TestFixture] + public sealed class EnableConfidentialCredits + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.EnableConfidentialCredits(Key(1), Key(2)).Data.Should().Equal(27, 9); + } + + [TestFixture] + public sealed class DisableConfidentialCredits + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.DisableConfidentialCredits(Key(1), Key(2)).Data.Should().Equal(27, 10); + } + + [TestFixture] + public sealed class EnableNonConfidentialCredits + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.EnableNonConfidentialCredits(Key(1), Key(2)).Data.Should().Equal(27, 11); + } + + [TestFixture] + public sealed class DisableNonConfidentialCredits + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.DisableNonConfidentialCredits(Key(1), Key(2)).Data.Should().Equal(27, 12); + } + + [TestFixture] + public sealed class InitializeConfidentialTransferFeeConfig + { + [Test] + public void MatchesPinnedInterfaceData() + { + // Act + var instruction = Token2022Program.InitializeConfidentialTransferFeeConfig( + Key(1), + null, + Pod(2, Token2022Program.ElGamalPublicKeyLength)); + + // Assert + Hex(instruction).Should().Be( + "2500" + new string('0', PublicKey.Length * 2) + + RepeatedHex(2, Token2022Program.ElGamalPublicKeyLength)); + } + } + + [TestFixture] + public sealed class WithdrawConfidentialWithheldTokensFromAccounts + { + [Test] + public void MatchesPinnedInterfaceDataAndAccountOrdering() + { + // Act + var instruction = Token2022Program.WithdrawConfidentialWithheldTokensFromAccounts( + Key(1), + Key(2), + Pod(3, Token2022Program.DecryptableBalanceLength), + Key(4), + [Key(7), Key(8)], + ConfidentialProofLocation.AtContextState(Key(5)), + [Key(6)]); + + // Assert + Hex(instruction).Should().Be( + "25020200" + RepeatedHex(3, Token2022Program.DecryptableBalanceLength)); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (Key(5), false, false), + (Key(4), false, false), + (Key(6), true, false), + (Key(7), false, true), + (Key(8), false, true)); + } + } + + [TestFixture] + public sealed class HarvestConfidentialWithheldTokensToMint + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.HarvestConfidentialWithheldTokensToMint(Key(1), [Key(2)]).Data + .Should().Equal(37, 3); + } + + [TestFixture] + public sealed class EnableConfidentialHarvestToMint + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.EnableConfidentialHarvestToMint(Key(1), Key(2)).Data.Should().Equal(37, 4); + } + + [TestFixture] + public sealed class DisableConfidentialHarvestToMint + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.DisableConfidentialHarvestToMint(Key(1), Key(2)).Data.Should().Equal(37, 5); + } + + [TestFixture] + public sealed class InitializeConfidentialMintBurn + { + [Test] + public void MatchesPinnedInterfaceData() + { + // Act + var instruction = Token2022Program.InitializeConfidentialMintBurn( + Key(1), + Pod(2, Token2022Program.ElGamalPublicKeyLength), + Pod(3, Token2022Program.DecryptableBalanceLength)); + + // Assert + Hex(instruction).Should().Be( + "2a00" + RepeatedHex(2, Token2022Program.ElGamalPublicKeyLength) + + RepeatedHex(3, Token2022Program.DecryptableBalanceLength)); + } + } + + [TestFixture] + public sealed class MintConfidentialTokens + { + [Test] + public void MatchesPinnedInterfaceDataAndProofOrdering() + { + // Act + var instruction = Token2022Program.MintConfidentialTokens( + Key(1), + Key(2), + Pod(3, Token2022Program.DecryptableBalanceLength), + Pod(4, Token2022Program.ElGamalCiphertextLength), + Pod(5, Token2022Program.ElGamalCiphertextLength), + Key(6), + ConfidentialProofLocation.AtContextState(Key(7)), + ConfidentialProofLocation.AtInstructionOffset(1), + ConfidentialProofLocation.AtContextState(Key(8))); + + // Assert + instruction.Data.Should().HaveCount(169); + instruction.Data.Take(2).Should().Equal(42, 3); + instruction.Data.TakeLast(3).Should().Equal(0, 1, 0); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (PublicKey.Parse(Sysvars.Instructions), false, false), + (Key(7), false, false), + (Key(8), false, false), + (Key(6), true, false)); + } + } + + [TestFixture] + public sealed class RotateConfidentialSupplyElGamalPublicKey + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.RotateConfidentialSupplyElGamalPublicKey( + Key(1), Key(2), Pod(3, 32), ConfidentialProofLocation.AtInstructionOffset(1)).Data.Take(2) + .Should().Equal(42, 1); + } + + [TestFixture] + public sealed class UpdateConfidentialDecryptableSupply + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.UpdateConfidentialDecryptableSupply(Key(1), Key(2), Pod(3, 36)).Data.Take(2) + .Should().Equal(42, 2); + } + + [TestFixture] + public sealed class BurnConfidentialTokens + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.BurnConfidentialTokens( + Key(1), + Key(2), + Pod(3, 36), + Pod(4, 64), + Pod(5, 64), + Key(6), + ConfidentialProofLocation.AtInstructionOffset(1), + ConfidentialProofLocation.AtInstructionOffset(2), + ConfidentialProofLocation.AtInstructionOffset(3)).Data.Take(2) + .Should().Equal(42, 4); + } + + [TestFixture] + public sealed class ApplyPendingConfidentialBurn + { + [Test] + public void UsesPinnedInnerTag() + => Token2022Program.ApplyPendingConfidentialBurn(Key(1), Key(2)).Data.Should().Equal(42, 5); + } +} + +public static class ElGamalProofProgramTests +{ + [TestFixture] + public sealed class VerifyProof + { + [Test] + public void MatchesPinnedNativeInterface() + { + // Act + var instruction = ElGamalProofProgram.VerifyProof( + ElGamalProofInstruction.VerifyPubkeyValidity, + Pod(3, 96), + Key(4), + Key(5)); + + // Assert + instruction.Data.Should().HaveCount(97); + instruction.Data[0].Should().Be(4); + instruction.Data.AsSpan(1).ToArray().Should().OnlyContain(value => value == 3); + Metas(instruction).Should().Equal((Key(4), false, true), (Key(5), false, false)); + } + } + + [TestFixture] + public sealed class VerifyProofFromAccount + { + [Test] + public void MatchesPinnedNativeInterface() + { + // Act + var instruction = ElGamalProofProgram.VerifyProofFromAccount( + ElGamalProofInstruction.VerifyBatchedRangeProofU128, + Key(6), + 0x11223344); + + // Assert + instruction.Data.Should().Equal(7, 0x44, 0x33, 0x22, 0x11); + Metas(instruction).Should().Equal((Key(6), false, false)); + } + } + + [TestFixture] + public sealed class CloseContextState + { + [Test] + public void MatchesPinnedNativeInterface() + { + // Act + var instruction = ElGamalProofProgram.CloseContextState(Key(1), Key(2), Key(3)); + + // Assert + instruction.Data.Should().Equal(0); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (Key(3), true, false)); + } + } +} + +public static class ElGamalRegistryProgramTests +{ + [TestFixture] + public sealed class CreateRegistry + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = ElGamalRegistryProgram.CreateRegistry( + Key(1), ConfidentialProofLocation.AtInstructionOffset(1)); + + // Assert + instruction.Data.Should().Equal(0, 1); + Metas(instruction).Should().Equal( + (ElGamalRegistryProgram.GetRegistryAddress(Key(1)), false, true), + (Key(1), true, false), + (PublicKey.Parse(SolanaProgramIds.SystemProgram), false, false), + (PublicKey.Parse(Sysvars.Instructions), false, false)); + } + } + + [TestFixture] + public sealed class UpdateRegistry + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = ElGamalRegistryProgram.UpdateRegistry( + Key(1), ConfidentialProofLocation.AtContextState(Key(2))); + + // Assert + instruction.Data.Should().Equal(1, 0); + Metas(instruction).Should().Equal( + (ElGamalRegistryProgram.GetRegistryAddress(Key(1)), false, true), + (Key(2), false, false), + (Key(1), true, false)); + } + } + + [TestFixture] + public sealed class DecodeState + { + [Test] + public void MatchesPinnedInterface() + { + // Arrange + var data = Key(3).ToBytes().Concat(Pod(4, 32)).ToArray(); + + // Act + var state = ElGamalRegistryProgram.DecodeState(data); + + // Assert + state.Should().NotBeNull(); + state!.Owner.Should().Be(Key(3)); + state.ElGamalPublicKey.ToArray().Should().Equal(Pod(4, 32)); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/Token2022InstructionDirectCoverageTests.cs b/tests/SolSharp.Programs.Tests/Token2022InstructionDirectCoverageTests.cs new file mode 100644 index 0000000..1aa4728 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/Token2022InstructionDirectCoverageTests.cs @@ -0,0 +1,297 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class Token2022InstructionDirectCoverageTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + private static byte[] Pod(byte value, int length) => [.. Enumerable.Repeat(value, length)]; + + private static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + private static string RepeatHex(byte value, int count) + => string.Concat(Enumerable.Repeat(value.ToString("x2"), count)); + + private static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; + + [TestFixture] + public sealed class UpdateConfidentialTransferMint + { + [Test] + public void AuditorPodAndMultisigAuthority_MatchPinnedWireLayout() + { + // Act + var instruction = Token2022Program.UpdateConfidentialTransferMint( + Key(1), Key(2), true, Pod(3, Token2022Program.ElGamalPublicKeyLength), [Key(4), Key(5)]); + + // Assert + Hex(instruction).Should().Be( + "1b0101" + RepeatHex(3, Token2022Program.ElGamalPublicKeyLength)); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(4), true, false), + (Key(5), true, false)); + } + + [Test] + public void WrongLengthAuditorPod_IsRejected() + { + // Act + Action act = () => _ = Token2022Program.UpdateConfidentialTransferMint( + Key(1), Key(2), false, Pod(3, Token2022Program.ElGamalPublicKeyLength - 1)); + + // Assert + act.Should().Throw().WithParameterName("auditorElGamalPublicKey"); + } + } + + [TestFixture] + public sealed class EmptyConfidentialTransferAccount + { + [Test] + public void InstructionOffset_UsesSignedWireByteAndInstructionsSysvarOnce() + { + // Act + var instruction = Token2022Program.EmptyConfidentialTransferAccount( + Key(1), Key(2), ConfidentialProofLocation.AtInstructionOffset(-2)); + + // Assert + Hex(instruction).Should().Be("1b04fe"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (PublicKey.Parse(Sysvars.Instructions), false, false), + (Key(2), true, false)); + instruction.Accounts.Count(account => account.PublicKey == PublicKey.Parse(Sysvars.Instructions)) + .Should().Be(1); + } + + [Test] + public void ContextProofAndMultisigAuthority_KeepPinnedAccountOrder() + { + // Act + var instruction = Token2022Program.EmptyConfidentialTransferAccount( + Key(1), Key(2), ConfidentialProofLocation.AtContextState(Key(3)), [Key(4)]); + + // Assert + Hex(instruction).Should().Be("1b0400"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(3), false, false), + (Key(2), false, false), + (Key(4), true, false)); + } + } + + [TestFixture] + public sealed class WithdrawConfidentialTokens + { + [Test] + public void MixedProofLocations_MatchPinnedPodAndAccountOrder() + { + // Arrange + var decryptableBalance = Pod(7, Token2022Program.DecryptableBalanceLength); + + // Act + var instruction = Token2022Program.WithdrawConfidentialTokens( + Key(1), + Key(2), + 0x0102030405060708, + 9, + decryptableBalance, + Key(3), + ConfidentialProofLocation.AtInstructionOffset(-3), + ConfidentialProofLocation.AtContextState(Key(4))); + + // Assert + Hex(instruction).Should().Be( + "1b06080706050403020109" + + RepeatHex(7, Token2022Program.DecryptableBalanceLength) + + "fd00"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (PublicKey.Parse(Sysvars.Instructions), false, false), + (Key(4), false, false), + (Key(3), true, false)); + instruction.Accounts.Count(account => account.PublicKey == PublicKey.Parse(Sysvars.Instructions)) + .Should().Be(1); + } + + [Test] + public void WrongLengthDecryptableBalance_IsRejected() + { + // Act + Action act = () => _ = Token2022Program.WithdrawConfidentialTokens( + Key(1), + Key(2), + 1, + 0, + Pod(7, Token2022Program.DecryptableBalanceLength - 1), + Key(3), + ConfidentialProofLocation.AtInstructionOffset(1), + ConfidentialProofLocation.AtInstructionOffset(2)); + + // Assert + act.Should().Throw().WithParameterName("newDecryptableAvailableBalance"); + } + } + + [TestFixture] + public sealed class WithdrawConfidentialWithheldTokensFromMint + { + [Test] + public void ContextProofAndMultisigAuthority_MatchPinnedWireLayout() + { + // Act + var instruction = Token2022Program.WithdrawConfidentialWithheldTokensFromMint( + Key(1), + Key(2), + Pod(8, Token2022Program.DecryptableBalanceLength), + Key(3), + ConfidentialProofLocation.AtContextState(Key(4)), + [Key(5)]); + + // Assert + Hex(instruction).Should().Be( + "250100" + RepeatHex(8, Token2022Program.DecryptableBalanceLength)); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (Key(4), false, false), + (Key(3), false, false), + (Key(5), true, false)); + } + + [Test] + public void WrongLengthDecryptableBalance_IsRejected() + { + // Act + Action act = () => _ = Token2022Program.WithdrawConfidentialWithheldTokensFromMint( + Key(1), + Key(2), + Pod(8, Token2022Program.DecryptableBalanceLength - 1), + Key(3), + ConfidentialProofLocation.AtInstructionOffset(1)); + + // Assert + act.Should().Throw().WithParameterName("newDecryptableAvailableBalance"); + } + } + + [TestFixture] + public sealed class UpdateTransferHook + { + [Test] + public void DirectAuthority_MatchesPinnedPointerLayout() + { + // Act + var instruction = Token2022Program.UpdateTransferHook(Key(1), Key(2), Key(3)); + + // Assert + Hex(instruction).Should().Be("2401" + RepeatHex(3, PublicKey.Length)); + Metas(instruction).Should().Equal((Key(1), false, true), (Key(2), true, false)); + } + + [Test] + public void AllZeroProgramAddress_IsRejectedAsAmbiguousNull() + { + // Act + Action act = () => _ = Token2022Program.UpdateTransferHook(Key(1), Key(2), default(PublicKey)); + + // Assert + act.Should().Throw().WithParameterName("transferHookProgramId"); + } + } + + [TestFixture] + public sealed class UpdateGroupPointer + { + [Test] + public void NullAddress_MatchesPinnedZeroPodLayout() + { + // Act + var instruction = Token2022Program.UpdateGroupPointer(Key(1), Key(2), null); + + // Assert + Hex(instruction).Should().Be("2801" + new string('0', PublicKey.Length * 2)); + Metas(instruction).Should().Equal((Key(1), false, true), (Key(2), true, false)); + } + } + + [TestFixture] + public sealed class UpdateGroupMemberPointer + { + [Test] + public void MultisigAuthority_MatchesPinnedPointerLayout() + { + // Act + var instruction = Token2022Program.UpdateGroupMemberPointer( + Key(1), Key(2), Key(3), [Key(4), Key(5)]); + + // Assert + Hex(instruction).Should().Be("2901" + RepeatHex(3, PublicKey.Length)); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(4), true, false), + (Key(5), true, false)); + } + } +} + +public static class TransferHookProgramDirectCoverageTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + private static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; + + [TestFixture] + public sealed class ExecuteWithExtraAccountMetas + { + [Test] + public void ResolvedMetas_AreAppendedAfterValidationAccountWithoutPrivilegeChanges() + { + // Arrange + AccountMeta[] additionalAccounts = + [ + AccountMeta.Writable(Key(6)), + AccountMeta.ReadonlySigner(Key(7)) + ]; + + // Act + var instruction = TransferHookProgram.ExecuteWithExtraAccountMetas( + Key(9), Key(1), Key(2), Key(3), Key(4), Key(5), additionalAccounts, 100); + + // Assert + instruction.ProgramId.Should().Be(Key(9)); + Convert.ToHexString(instruction.Data).ToLowerInvariant().Should().Be( + "692565c54bfb661a6400000000000000"); + Metas(instruction).Should().Equal( + (Key(1), false, false), + (Key(2), false, false), + (Key(3), false, false), + (Key(4), false, false), + (Key(5), false, false), + (Key(6), false, true), + (Key(7), true, false)); + } + + [Test] + public void NullAdditionalAccounts_IsRejected() + { + // Act + Action act = () => _ = TransferHookProgram.ExecuteWithExtraAccountMetas( + Key(9), Key(1), Key(2), Key(3), Key(4), Key(5), null!, 1); + + // Assert + act.Should().Throw().WithParameterName("additionalAccounts"); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/Token2022MetadataInstructionTests.cs b/tests/SolSharp.Programs.Tests/Token2022MetadataInstructionTests.cs new file mode 100644 index 0000000..87d5c98 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/Token2022MetadataInstructionTests.cs @@ -0,0 +1,107 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class Token2022MetadataInstructionTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + private static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + private static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; + + [TestFixture] + public sealed class Initialize + { + [Test] + public void MatchesPinnedMetadataInterface() + { + // Act + var instruction = Token2022Program.InitializeTokenMetadata( + Key(1), Key(2), Key(3), Key(4), "A", "B", "C"); + + // Assert + Hex(instruction).Should().Be( + "d2e11ea258b84d8d010000004101000000420100000043"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(3), false, false), + (Key(4), true, false)); + } + } + + [TestFixture] + public sealed class UpdateField + { + [Test] + public void RequiredAndCustomFieldsMatchPinnedMetadataInterface() + { + // Act + var required = Token2022Program.UpdateTokenMetadataField(Key(1), Key(2), TokenMetadataField.Uri, "U"); + var custom = Token2022Program.UpdateTokenMetadataField(Key(1), Key(2), "K", "V"); + + // Assert + Hex(required).Should().Be("dde9312db5cadcc8020100000055"); + Hex(custom).Should().Be("dde9312db5cadcc803010000004b0100000056"); + Metas(custom).Should().Equal((Key(1), false, true), (Key(2), true, false)); + } + + [Test] + public void InvalidUnicodeIsRejected() + { + // Act + Action act = () => _ = Token2022Program.UpdateTokenMetadataField( + Key(1), Key(2), TokenMetadataField.Name, "\ud800"); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class RemoveKey + { + [Test] + public void MatchesPinnedMetadataInterface() + { + // Act + var instruction = Token2022Program.RemoveTokenMetadataKey(Key(1), Key(2), "K", idempotent: true); + + // Assert + Hex(instruction).Should().Be("ea122038598d25b501010000004b"); + } + } + + [TestFixture] + public sealed class UpdateAuthority + { + [Test] + public void NullAuthorityMatchesPinnedMetadataInterface() + { + // Act + var instruction = Token2022Program.UpdateTokenMetadataAuthority(Key(1), Key(2), null); + + // Assert + Hex(instruction).Should().Be("d7e4a6e45464567b" + new string('0', PublicKey.Length * 2)); + } + } + + [TestFixture] + public sealed class Emit + { + [Test] + public void RangeMatchesPinnedMetadataInterface() + { + // Act + var instruction = Token2022Program.EmitTokenMetadata(Key(1), 2, 10); + + // Assert + Hex(instruction).Should().Be("faa6b4fa0d0cb846010200000000000000010a00000000000000"); + Metas(instruction).Should().Equal((Key(1), false, false)); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/Token2022MintExtensionTests.cs b/tests/SolSharp.Programs.Tests/Token2022MintExtensionTests.cs new file mode 100644 index 0000000..3f6ac8a --- /dev/null +++ b/tests/SolSharp.Programs.Tests/Token2022MintExtensionTests.cs @@ -0,0 +1,201 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class Token2022MintExtensionTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + private static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + private static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; + + private static void AssertPointerInitializer( + Func build, + byte outerDiscriminator) + { + // Act + var instruction = build(Key(1), Key(2), Key(3)); + + // Assert + Hex(instruction).Should().Be( + outerDiscriminator.ToString("x2") + "00" + + "0202020202020202020202020202020202020202020202020202020202020202" + + "0303030303030303030303030303030303030303030303030303030303030303"); + Metas(instruction).Should().Equal((Key(1), false, true)); + } + + [TestFixture] + public sealed class InitializeInterestBearingMint + { + [Test] + public void MatchesPinnedInterface() => + // Act & Assert + Hex(Token2022Program.InitializeInterestBearingMint(Key(1), Key(2), -25)).Should().Be( + "2100" + "0202020202020202020202020202020202020202020202020202020202020202" + "e7ff"); + } + + [TestFixture] + public sealed class UpdateInterestRate + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = Token2022Program.UpdateInterestRate(Key(1), Key(2), 250, [Key(3)]); + + // Assert + Hex(instruction).Should().Be("2101fa00"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(3), true, false)); + } + } + + [TestFixture] + public sealed class InitializeTransferHook + { + [Test] + public void MatchesPinnedInterface() => + AssertPointerInitializer(Token2022Program.InitializeTransferHook, 36); + } + + [TestFixture] + public sealed class InitializeMetadataPointer + { + [Test] + public void MatchesPinnedInterface() => + AssertPointerInitializer(Token2022Program.InitializeMetadataPointer, 39); + + [Test] + public void MaybeNullRejectsAmbiguousZeroAddress() + { + // Act + Action act = () => _ = Token2022Program.InitializeMetadataPointer(Key(1), default(PublicKey), Key(2)); + + // Assert + act.Should().Throw().WithParameterName("authority"); + } + } + + [TestFixture] + public sealed class InitializeGroupPointer + { + [Test] + public void MatchesPinnedInterface() => + AssertPointerInitializer(Token2022Program.InitializeGroupPointer, 40); + } + + [TestFixture] + public sealed class InitializeGroupMemberPointer + { + [Test] + public void MatchesPinnedInterface() => + AssertPointerInitializer(Token2022Program.InitializeGroupMemberPointer, 41); + } + + [TestFixture] + public sealed class UpdateMetadataPointer + { + [Test] + public void MatchesPinnedMultisigLayout() + { + // Act + var instruction = Token2022Program.UpdateMetadataPointer(Key(1), Key(2), Key(3), [Key(4), Key(5)]); + + // Assert + Hex(instruction).Should().Be( + "2701" + "0303030303030303030303030303030303030303030303030303030303030303"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(4), true, false), + (Key(5), true, false)); + } + } + + [TestFixture] + public sealed class InitializeScaledUiAmount + { + [Test] + public void MatchesPinnedPodLayout() => + Hex(Token2022Program.InitializeScaledUiAmount(Key(1), Key(2), 1.5)).Should().Be( + "2b00" + "0202020202020202020202020202020202020202020202020202020202020202" + + "000000000000f83f"); + } + + [TestFixture] + public sealed class UpdateScaledUiAmount + { + [Test] + public void MatchesPinnedPodLayout() => + Hex(Token2022Program.UpdateScaledUiAmount(Key(1), Key(2), 2.0, 42)) + .Should().Be("2b0100000000000000402a00000000000000"); + } + + [TestFixture] + public sealed class InitializePausableMint + { + [Test] + public void MatchesPinnedInterface() => + Hex(Token2022Program.InitializePausableMint(Key(1), Key(2))).Should().Be( + "2c00" + "0202020202020202020202020202020202020202020202020202020202020202"); + } + + [TestFixture] + public sealed class PauseMint + { + [Test] + public void MatchesPinnedInterface() => + Hex(Token2022Program.PauseMint(Key(1), Key(2))).Should().Be("2c01"); + } + + [TestFixture] + public sealed class ResumeMint + { + [Test] + public void MatchesPinnedInterface() => + Hex(Token2022Program.ResumeMint(Key(1), Key(2))).Should().Be("2c02"); + } + + [TestFixture] + public sealed class InitializePermissionedBurn + { + [Test] + public void MatchesPinnedInterface() => + Hex(Token2022Program.InitializePermissionedBurn(Key(1), Key(2))).Should().Be( + "2e00" + "0202020202020202020202020202020202020202020202020202020202020202"); + } + + [TestFixture] + public sealed class PermissionedBurn + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = Token2022Program.PermissionedBurn(Key(1), Key(2), Key(3), Key(4), 42); + + // Assert + Hex(instruction).Should().Be("2e012a00000000000000"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (Key(3), true, false), + (Key(4), true, false)); + } + } + + [TestFixture] + public sealed class PermissionedBurnChecked + { + [Test] + public void MatchesPinnedInterface() => + Hex(Token2022Program.PermissionedBurnChecked(Key(1), Key(2), Key(3), Key(4), 42, 6)) + .Should().Be("2e022a0000000000000006"); + } +} diff --git a/tests/SolSharp.Programs.Tests/Token2022PermissionedBurnTests.cs b/tests/SolSharp.Programs.Tests/Token2022PermissionedBurnTests.cs new file mode 100644 index 0000000..20372e1 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/Token2022PermissionedBurnTests.cs @@ -0,0 +1,223 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class Token2022PermissionedBurnTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + private static byte[] Pod(byte value, int length) => [.. Enumerable.Repeat(value, length)]; + + private static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + private static string RepeatedHex(byte value, int length) + => string.Concat(Enumerable.Repeat(value.ToString("x2"), length)); + + private static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; + + private static Instruction Build( + ConfidentialProofLocation equalityProofLocation, + ConfidentialProofLocation ciphertextValidityProofLocation, + ConfidentialProofLocation rangeProofLocation, + IReadOnlyList? multisigSigners = null) + => Token2022Program.BurnPermissionedConfidentialTokens( + Key(1), + Key(2), + Key(3), + Pod(0x11, Token2022Program.DecryptableBalanceLength), + Pod(0x22, Token2022Program.ElGamalCiphertextLength), + Pod(0x33, Token2022Program.ElGamalCiphertextLength), + Key(4), + equalityProofLocation, + ciphertextValidityProofLocation, + rangeProofLocation, + multisigSigners); + + [TestFixture] + public sealed class BurnPermissionedConfidentialTokens + { + [Test] + public void MixedProofsMatchPinnedDataAndAccountLayout() + { + // Act + var instruction = Build( + ConfidentialProofLocation.AtInstructionOffset(-1), + ConfidentialProofLocation.AtContextState(Key(5)), + ConfidentialProofLocation.AtInstructionOffset(2)); + var decoded = TokenProgram.DecodeInstructionData(instruction.Data); + + // Assert + instruction.ProgramId.Should().Be(Token2022Program.ProgramId); + instruction.Data.Should().HaveCount(169); + Hex(instruction).Should().Be( + "2e03" + + RepeatedHex(0x11, Token2022Program.DecryptableBalanceLength) + + RepeatedHex(0x22, Token2022Program.ElGamalCiphertextLength) + + RepeatedHex(0x33, Token2022Program.ElGamalCiphertextLength) + + "ff0002"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (PublicKey.Parse(Sysvars.Instructions), false, false), + (Key(5), false, false), + (Key(3), true, false), + (Key(4), true, false)); + decoded.Should().NotBeNull(); + decoded!.Name.Should().Be("PermissionedBurnExtension"); + decoded.ExtensionInstructionDiscriminator.Should().Be(3); + } + + [Test] + public void MixedProofContextsRemainInProofOrderAndUseOneSysvar() + { + // Act + var instruction = Build( + ConfidentialProofLocation.AtContextState(Key(5)), + ConfidentialProofLocation.AtInstructionOffset(-2), + ConfidentialProofLocation.AtContextState(Key(6))); + + // Assert + instruction.Data.TakeLast(3).Should().Equal(0, 0xfe, 0); + instruction.Accounts.Count(account => account.PublicKey == PublicKey.Parse(Sysvars.Instructions)).Should().Be(1); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (PublicKey.Parse(Sysvars.Instructions), false, false), + (Key(5), false, false), + (Key(6), false, false), + (Key(3), true, false), + (Key(4), true, false)); + } + + [Test] + public void ContextProofsOmitInstructionsSysvar() + { + // Act + var instruction = Build( + ConfidentialProofLocation.AtContextState(Key(5)), + ConfidentialProofLocation.AtContextState(Key(6)), + ConfidentialProofLocation.AtContextState(Key(7))); + + // Assert + instruction.Data.TakeLast(3).Should().Equal(0, 0, 0); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (Key(5), false, false), + (Key(6), false, false), + (Key(7), false, false), + (Key(3), true, false), + (Key(4), true, false)); + } + + [Test] + public void InstructionOffsetBoundsUseOneSysvarAndSignedWireBytes() + { + // Act + var instruction = Build( + ConfidentialProofLocation.AtInstructionOffset(sbyte.MinValue), + ConfidentialProofLocation.AtInstructionOffset(1), + ConfidentialProofLocation.AtInstructionOffset(sbyte.MaxValue)); + + // Assert + instruction.Data.TakeLast(3).Should().Equal(0x80, 0x01, 0x7f); + instruction.Accounts.Count(account => account.PublicKey == PublicKey.Parse(Sysvars.Instructions)).Should().Be(1); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (PublicKey.Parse(Sysvars.Instructions), false, false), + (Key(3), true, false), + (Key(4), true, false)); + } + + [Test] + public void MultisigAcceptsElevenMembersAndPreservesBothAuthorities() + { + // Arrange + var signers = Enumerable.Range(10, 11).Select(value => Key(checked((byte)value))).ToArray(); + + // Act + var instruction = Build( + ConfidentialProofLocation.AtContextState(Key(5)), + ConfidentialProofLocation.AtContextState(Key(6)), + ConfidentialProofLocation.AtContextState(Key(7)), + signers); + + // Assert + instruction.Accounts[5].Should().BeEquivalentTo(AccountMeta.ReadonlySigner(Key(3))); + instruction.Accounts[6].Should().BeEquivalentTo(AccountMeta.Readonly(Key(4))); + instruction.Accounts.Skip(7).Should().Equal(signers.Select(AccountMeta.ReadonlySigner)); + } + + [Test] + public void MultisigRejectsMoreThanElevenMembers() + { + // Arrange + var signers = Enumerable.Range(10, 12).Select(value => Key(checked((byte)value))).ToArray(); + + // Act + Action act = () => _ = Build( + ConfidentialProofLocation.AtContextState(Key(5)), + ConfidentialProofLocation.AtContextState(Key(6)), + ConfidentialProofLocation.AtContextState(Key(7)), + signers); + + // Assert + act.Should().Throw().WithParameterName("multisigSigners"); + } + + [TestCase(35, 64, 64, "newDecryptableAvailableBalance")] + [TestCase(37, 64, 64, "newDecryptableAvailableBalance")] + [TestCase(36, 63, 64, "auditorCiphertextLow")] + [TestCase(36, 65, 64, "auditorCiphertextLow")] + [TestCase(36, 64, 63, "auditorCiphertextHigh")] + [TestCase(36, 64, 65, "auditorCiphertextHigh")] + public void InvalidPodLengthsAreRejected( + int decryptableBalanceLength, + int lowCiphertextLength, + int highCiphertextLength, + string expectedParameterName) + { + // Act + Action act = () => _ = Token2022Program.BurnPermissionedConfidentialTokens( + Key(1), + Key(2), + Key(3), + Pod(0x11, decryptableBalanceLength), + Pod(0x22, lowCiphertextLength), + Pod(0x33, highCiphertextLength), + Key(4), + ConfidentialProofLocation.AtContextState(Key(5)), + ConfidentialProofLocation.AtContextState(Key(6)), + ConfidentialProofLocation.AtContextState(Key(7))); + + // Assert + act.Should().Throw().WithParameterName(expectedParameterName); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + public void NullProofLocationsAreRejected(int nullProofIndex) + { + // Arrange + var proofLocations = new[] + { + ConfidentialProofLocation.AtContextState(Key(5)), + ConfidentialProofLocation.AtContextState(Key(6)), + ConfidentialProofLocation.AtContextState(Key(7)) + }; + proofLocations[nullProofIndex] = null!; + + // Act + Action act = () => _ = Build(proofLocations[0], proofLocations[1], proofLocations[2]); + + // Assert + act.Should().Throw().WithParameterName("proofLocations"); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/Token2022ProgramTests.cs b/tests/SolSharp.Programs.Tests/Token2022ProgramTests.cs new file mode 100644 index 0000000..2905559 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/Token2022ProgramTests.cs @@ -0,0 +1,142 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class Token2022ProgramTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + private static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + private static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; + + [TestFixture] + public sealed class GetAccountDataSize + { + [Test] + public void MatchesPinnedToken2022Interface() + { + // Act + var instruction = Token2022Program.GetAccountDataSize( + Key(1), + [Token2022ExtensionType.TransferFeeConfig, Token2022ExtensionType.MemoTransfer]); + + // Assert + instruction.ProgramId.Should().Be(PublicKey.Parse(SolanaProgramIds.Token2022Program)); + Hex(instruction).Should().Be("1501000800"); + Metas(instruction).Should().Equal((Key(1), false, false)); + } + + [Test] + public void RejectsUnknownExtensionType() + { + // Act + Action act = () => _ = Token2022Program.GetAccountDataSize( + Key(1), [(Token2022ExtensionType)ushort.MaxValue]); + + // Assert + act.Should().Throw().WithParameterName("extensionTypes"); + } + } + + [TestFixture] + public sealed class InitializeMintCloseAuthority + { + [Test] + public void MatchesPinnedToken2022Interface() + { + // Act + var none = Token2022Program.InitializeMintCloseAuthority(Key(1), null); + var some = Token2022Program.InitializeMintCloseAuthority(Key(1), Key(2)); + + // Assert + Hex(none).Should().Be("1900"); + Hex(some).Should().Be( + "1901" + "0202020202020202020202020202020202020202020202020202020202020202"); + Metas(some).Should().Equal((Key(1), false, true)); + } + } + + [TestFixture] + public sealed class Reallocate + { + [Test] + public void MatchesPinnedToken2022Interface() + { + // Act + var instruction = Token2022Program.Reallocate( + Key(1), + Key(2), + Key(3), + [Token2022ExtensionType.MemoTransfer, Token2022ExtensionType.CpiGuard]); + + // Assert + Hex(instruction).Should().Be("1d08000b00"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), true, true), + (SystemProgram.ProgramId, false, false), + (Key(3), true, false)); + } + + [Test] + public void SupportsMultisigOwner() + { + // Act + var instruction = Token2022Program.Reallocate( + Key(1), + Key(2), + Key(3), + [Token2022ExtensionType.MemoTransfer], + [Key(4), Key(5)]); + + // Assert + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), true, true), + (SystemProgram.ProgramId, false, false), + (Key(3), false, false), + (Key(4), true, false), + (Key(5), true, false)); + } + } + + [TestFixture] + public sealed class CreateNativeMint + { + [Test] + public void MatchesPinnedToken2022Interface() + { + // Act + var instruction = Token2022Program.CreateNativeMint(Key(1)); + + // Assert + Hex(instruction).Should().Be("1f"); + Metas(instruction).Should().Equal( + (Key(1), true, true), + (PublicKey.Parse(Mints.WrappedSol), false, true), + (SystemProgram.ProgramId, false, false)); + } + } + + [TestFixture] + public sealed class InitializeNonTransferableMint + { + [Test] + public void MatchesPinnedToken2022Interface() => + Hex(Token2022Program.InitializeNonTransferableMint(Key(2))).Should().Be("20"); + } + + [TestFixture] + public sealed class InitializePermanentDelegate + { + [Test] + public void MatchesPinnedToken2022Interface() => + Hex(Token2022Program.InitializePermanentDelegate(Key(2), Key(3))).Should().Be( + "23" + "0303030303030303030303030303030303030303030303030303030303030303"); + } +} diff --git a/tests/SolSharp.Programs.Tests/Token2022TransferFeeTests.cs b/tests/SolSharp.Programs.Tests/Token2022TransferFeeTests.cs new file mode 100644 index 0000000..6874853 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/Token2022TransferFeeTests.cs @@ -0,0 +1,133 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class Token2022TransferFeeTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + private static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + private static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; + + [TestFixture] + public sealed class InitializeTransferFeeConfig + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = Token2022Program.InitializeTransferFeeConfig(Key(1), Key(2), null, 250, 10_000); + + // Assert + Hex(instruction).Should().Be( + "1a0001" + "0202020202020202020202020202020202020202020202020202020202020202" + + "00fa001027000000000000"); + Metas(instruction).Should().Equal((Key(1), false, true)); + } + } + + [TestFixture] + public sealed class TransferCheckedWithFee + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = Token2022Program.TransferCheckedWithFee( + Key(1), Key(2), Key(3), Key(4), 1_000, 6, 25); + + // Assert + Hex(instruction).Should().Be("1a01e803000000000000061900000000000000"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(3), false, true), + (Key(4), true, false)); + } + } + + [TestFixture] + public sealed class WithdrawWithheldTokensFromMint + { + [Test] + public void MatchesPinnedMultisigLayout() + { + // Act + var instruction = Token2022Program.WithdrawWithheldTokensFromMint(Key(1), Key(2), Key(3), [Key(4)]); + + // Assert + Hex(instruction).Should().Be("1a02"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (Key(3), false, false), + (Key(4), true, false)); + } + } + + [TestFixture] + public sealed class WithdrawWithheldTokensFromAccounts + { + [Test] + public void KeepsSignersBeforeSources() + { + // Act + var instruction = Token2022Program.WithdrawWithheldTokensFromAccounts( + Key(1), + Key(2), + Key(3), + [Key(6), Key(7)], + [Key(4), Key(5)]); + + // Assert + Hex(instruction).Should().Be("1a0302"); + Metas(instruction).Should().Equal( + (Key(1), false, false), + (Key(2), false, true), + (Key(3), false, false), + (Key(4), true, false), + (Key(5), true, false), + (Key(6), false, true), + (Key(7), false, true)); + } + } + + [TestFixture] + public sealed class HarvestWithheldTokensToMint + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = Token2022Program.HarvestWithheldTokensToMint(Key(1), [Key(2), Key(3)]); + + // Assert + Hex(instruction).Should().Be("1a04"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (Key(3), false, true)); + } + } + + [TestFixture] + public sealed class SetTransferFee + { + [Test] + public void MatchesPinnedInterface() + { + // Act + var instruction = Token2022Program.SetTransferFee(Key(1), Key(2), 100, 500); + + // Assert + Hex(instruction).Should().Be("1a056400f401000000000000"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), true, false)); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/TokenDecoderTests.cs b/tests/SolSharp.Programs.Tests/TokenDecoderTests.cs new file mode 100644 index 0000000..d3acfa2 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/TokenDecoderTests.cs @@ -0,0 +1,444 @@ +using System.Buffers.Binary; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; +using static SolSharp.Programs.Tests.TokenDecoderTestHelpers; + +namespace SolSharp.Programs.Tests; + +internal static class TokenDecoderTestHelpers +{ + internal static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + internal static byte[] MintData() + { + var data = new byte[TokenMintState.BaseLength]; + BinaryPrimitives.WriteUInt32LittleEndian(data, 1); + Key(1).CopyTo(data.AsSpan(4)); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(36), 500); + data[44] = 6; + data[45] = 1; + return data; + } + + internal static byte[] ExtendedMintData(int length) + { + var data = new byte[length]; + MintData().CopyTo(data, 0); + data[165] = 1; + return data; + } + + internal static byte[] ExtendedHoldingData(int length) + { + var data = new byte[length]; + Key(1).CopyTo(data); + Key(2).CopyTo(data.AsSpan(PublicKey.Length)); + data[108] = (byte)TokenAccountStatus.Initialized; + data[165] = 2; + return data; + } + + internal static void WriteString(List data, string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + WriteUInt32(data, checked((uint)bytes.Length)); + data.AddRange(bytes); + } + + internal static void WriteUInt32(List data, uint value) + { + Span bytes = stackalloc byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, value); + data.AddRange(bytes.ToArray()); + } +} + +public static class TokenDecoderTests +{ + [TestFixture] + public sealed class DecodeInstructionData + { + [Test] + public void ClassicFixedAndOptionalFields_AreDecoded() + { + // Arrange + var initialize = TokenProgram.InitializeMint(Key(1), 6, Key(2), Key(3)); + var setAuthority = TokenProgram.SetAuthority(Key(1), Key(2), AuthorityType.CloseAccount, null); + var checkedTransfer = TokenProgram.TransferChecked(Key(1), Key(2), Key(3), Key(4), 500, 6); + + // Act + var decodedInitialize = TokenProgram.DecodeInstructionData(initialize.Data); + var decodedAuthority = TokenProgram.DecodeInstructionData(setAuthority.Data); + var decodedTransfer = TokenProgram.DecodeInstructionData(checkedTransfer.Data); + + // Assert + decodedInitialize.Should().NotBeNull(); + decodedInitialize!.Discriminator.Should().Be(initialize.Data[0]); + decodedInitialize.Payload.ToArray().Should().Equal(initialize.Data[1..]); + decodedInitialize.Name.Should().Be("InitializeMint"); + decodedInitialize.Decimals.Should().Be(6); + decodedInitialize.RelatedPublicKey.Should().Be(Key(2)); + decodedInitialize.OptionalPublicKey.Should().Be(Key(3)); + decodedAuthority.Should().NotBeNull(); + decodedAuthority!.AuthorityType.Should().Be(AuthorityType.CloseAccount); + decodedAuthority.HasOptionalPublicKey.Should().BeTrue(); + decodedAuthority.OptionalPublicKey.Should().BeNull(); + decodedTransfer.Should().NotBeNull(); + decodedTransfer!.Amount.Should().Be(500); + decodedTransfer.Decimals.Should().Be(6); + } + + [Test] + public void ExtensionsDeprecatedStableVariantsAndBatch_AreDecoded() + { + // Arrange + var confidential = Token2022Program.EnableConfidentialCredits(Key(1), Key(2)); + var unwrap = TokenProgram.UnwrapLamports(Key(1), Key(2), Key(3), 100); + var accountDataSize = Token2022Program.GetAccountDataSize( + Key(1), [Token2022ExtensionType.TransferFeeConfig, Token2022ExtensionType.MemoTransfer]); + var batch = TokenProgram.Batch( + [ + TokenProgram.SyncNative(Key(1), Token2022Program.ProgramId), + TokenProgram.Transfer(Key(1), Key(2), Key(3), 4, Token2022Program.ProgramId) + ]); + + // Act + var decodedConfidential = TokenProgram.DecodeInstructionData(confidential.Data); + var decodedUnwrap = TokenProgram.DecodeInstructionData(unwrap.Data); + var decodedAccountDataSize = TokenProgram.DecodeInstructionData(accountDataSize.Data); + var decodedBatch = TokenProgram.DecodeInstructionData(batch.Data); + + // Assert + decodedConfidential.Should().NotBeNull(); + decodedConfidential!.Name.Should().Be("ConfidentialTransferExtension"); + decodedConfidential.ExtensionInstructionDiscriminator.Should().Be(9); + decodedUnwrap.Should().NotBeNull(); + decodedUnwrap!.HasOptionalAmount.Should().BeTrue(); + decodedUnwrap.Amount.Should().Be(100); + decodedAccountDataSize.Should().NotBeNull(); + decodedAccountDataSize!.Discriminator.Should().Be(21); + decodedAccountDataSize.Payload.ToArray().Should().Equal(accountDataSize.Data[1..]); + decodedAccountDataSize.ExtensionTypes.Should().Equal( + Token2022ExtensionType.TransferFeeConfig, + Token2022ExtensionType.MemoTransfer); + TokenProgram.DecodeInstructionData([255]).Should().BeNull(); + TokenProgram.DecodeInstructionData([255, 0, 0]).Should().BeNull(); + decodedBatch.Should().NotBeNull(); + decodedBatch!.BatchEntries.Select(entry => (entry.AccountCount, entry.Data.ToArray())).Should().SatisfyRespectively( + first => + { + first.AccountCount.Should().Be(1); + first.Item2.Should().Equal(17); + }, + second => + { + second.AccountCount.Should().Be(3); + second.Item2.Should().Equal(TokenProgram.Transfer(Key(1), Key(2), Key(3), 4).Data); + }); + } + + [Test] + public void MalformedPinnedLayouts_AreRejected() + { + // Act & Assert + TokenProgram.DecodeInstructionData([]).Should().BeNull(); + TokenProgram.DecodeInstructionData([12, 1]).Should().BeNull(); + TokenProgram.DecodeInstructionData([6, 255, 0]).Should().BeNull(); + TokenProgram.DecodeInstructionData([29, 1]).Should().BeNull(); + TokenProgram.DecodeInstructionData([255, 1, 3, 17]).Should().BeNull(); + } + + [Test] + public void InterfaceDiscriminatorDecoders_MatchBuilders() + { + // Arrange + var hook = TransferHookProgram.Execute(Key(9), Key(1), Key(2), Key(3), Key(4), 5); + + // Act + var decodedHook = TransferHookProgram.DecodeInstructionData(hook.Data); + var decodedAssociatedToken = AssociatedTokenAccount.DecodeInstructionData([1]); + var malformedAssociatedToken = AssociatedTokenAccount.DecodeInstructionData([1, 0]); + + // Assert + decodedHook!.Name.Should().Be("Execute"); + decodedAssociatedToken.Should().Be("CreateIdempotent"); + malformedAssociatedToken.Should().BeNull(); + } + } + + [TestFixture] + public sealed class DecodeTokenMetadataInstructionData + { + [Test] + public void InterfaceDiscriminator_MatchesBuilder() + { + // Arrange + var metadata = Token2022Program.InitializeTokenMetadata( + Key(1), Key(2), Key(3), Key(4), "name", "SYM", "uri"); + + // Act + var decoded = Token2022Program.DecodeTokenMetadataInstructionData(metadata.Data); + + // Assert + decoded!.Name.Should().Be("Initialize"); + decoded.Payload.ToArray().Should().Equal(metadata.Data[8..]); + } + } + + [TestFixture] + public sealed class DecodeTokenGroupInstructionData + { + [Test] + public void InterfaceDiscriminator_MatchesBuilder() + { + // Arrange + var group = Token2022Program.UpdateTokenGroupMaxSize(Key(1), Key(2), 3); + + // Act + var decoded = Token2022Program.DecodeTokenGroupInstructionData(group.Data); + + // Assert + decoded!.Name.Should().Be("UpdateGroupMaxSize"); + decoded.Payload.ToArray().Should().Equal(group.Data[8..]); + } + } +} + +public static class TokenMintStateDecoderTests +{ + [TestFixture] + public sealed class Decode + { + [Test] + public void ClassicAndExtendedState_IsDecoded() + { + // Arrange + var mintData = MintData(); + var extendedData = new byte[166 + 4 + 3]; + mintData.CopyTo(extendedData, 0); + extendedData[165] = 1; + BinaryPrimitives.WriteUInt16LittleEndian( + extendedData.AsSpan(166), (ushort)Token2022ExtensionType.ScaledUiAmount); + BinaryPrimitives.WriteUInt16LittleEndian(extendedData.AsSpan(168), 3); + extendedData.AsSpan(170).Fill(9); + + // Act + var classic = TokenMintState.Decode(mintData); + var extended = TokenMintState.Decode(extendedData); + + // Assert + classic.Should().NotBeNull(); + classic!.MintAuthority.Should().Be(Key(1)); + classic.Supply.Should().Be(500); + classic.Decimals.Should().Be(6); + classic.IsInitialized.Should().BeTrue(); + classic.FreezeAuthority.Should().BeNull(); + classic.Extensions.Should().BeEmpty(); + extended.Should().NotBeNull(); + extended!.Extensions.Should().ContainSingle(); + extended.Extensions[0].ExtensionType.Should().Be(Token2022ExtensionType.ScaledUiAmount); + extended.Extensions[0].Data.ToArray().Should().Equal("\t\t\t"u8.ToArray()); + } + + [Test] + public void MultisigReservedLength_IsRejected() + { + // Arrange + var data = ExtendedMintData(TokenMultisigState.Length); + + // Act & Assert + TokenMintState.Decode(data).Should().BeNull(); + } + + [TestCase(81)] + [TestCase(83)] + [TestCase(164)] + [TestCase(165)] + public void IntermediateEnvelopeLengths_AreRejected(int length) + { + // Arrange + var data = new byte[length]; + MintData().AsSpan(0, Math.Min(length, TokenMintState.BaseLength)).CopyTo(data); + + // Act & Assert + TokenMintState.Decode(data).Should().BeNull(); + } + + [TestCase(354)] + [TestCase(356)] + [TestCase(357)] + public void NeighboringExtendedLengths_AreNotMistakenForMultisig(int length) + { + // Arrange + var data = ExtendedMintData(length); + + // Act + var state = TokenMintState.Decode(data); + + // Assert + state.Should().NotBeNull(); + state!.Extensions.Should().BeEmpty(); + } + + [Test] + public void TruncatedClassicState_IsRejected() => + // Act & Assert + TokenMintState.Decode(MintData().AsSpan()[..^1]).Should().BeNull(); + } +} + +public static class TokenHoldingAccountStateDecoderTests +{ + [TestFixture] + public sealed class Decode + { + [Test] + public void ClassicAndExtendedState_IsDecoded() + { + // Arrange + var data = new byte[166 + 4 + 1]; + Key(1).CopyTo(data); + Key(2).CopyTo(data.AsSpan(32)); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(64), 100); + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(72), 1); + Key(3).CopyTo(data.AsSpan(76)); + data[108] = (byte)TokenAccountStatus.Frozen; + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(109), 1); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(113), 2_039_280); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(121), 25); + data[165] = 2; + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(166), (ushort)Token2022ExtensionType.MemoTransfer); + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(168), 1); + data[170] = 1; + + // Act + var state = TokenHoldingAccountState.Decode(data); + + // Assert + state.Should().NotBeNull(); + state!.Mint.Should().Be(Key(1)); + state.Owner.Should().Be(Key(2)); + state.Amount.Should().Be(100); + state.Delegate.Should().Be(Key(3)); + state.Status.Should().Be(TokenAccountStatus.Frozen); + state.NativeRentExemptReserve.Should().Be(2_039_280); + state.DelegatedAmount.Should().Be(25); + state.Extensions.Should().ContainSingle(); + } + + [Test] + public void MultisigReservedLength_IsRejected() + { + // Arrange + var data = ExtendedHoldingData(TokenMultisigState.Length); + + // Act & Assert + TokenHoldingAccountState.Decode(data).Should().BeNull(); + } + + [TestCase(164)] + public void IntermediateEnvelopeLengths_AreRejected(int length) + { + // Arrange + var data = new byte[length]; + + // Act & Assert + TokenHoldingAccountState.Decode(data).Should().BeNull(); + } + + [TestCase(354)] + [TestCase(356)] + [TestCase(357)] + public void NeighboringExtendedLengths_AreNotMistakenForMultisig(int length) + { + // Arrange + var data = ExtendedHoldingData(length); + + // Act + var state = TokenHoldingAccountState.Decode(data); + + // Assert + state.Should().NotBeNull(); + state!.Extensions.Should().BeEmpty(); + } + } +} + +public static class TokenMultisigStateDecoderTests +{ + [TestFixture] + public sealed class Decode + { + [Test] + public void ValidState_IsDecodedAndMalformedFlagIsRejected() + { + // Arrange + var data = new byte[TokenMultisigState.Length]; + data[0] = 2; + data[1] = 3; + data[2] = 1; + for (var i = 0; i < 11; i++) + Key(checked((byte)(i + 1))).CopyTo(data.AsSpan(3 + (i * PublicKey.Length))); + + // Act + var state = TokenMultisigState.Decode(data); + + // Assert + state.Should().NotBeNull(); + state!.RequiredSignatures.Should().Be(2); + state.SignerCount.Should().Be(3); + state.Signers.Take(3).Should().Equal(Key(1), Key(2), Key(3)); + data[2] = 2; + TokenMultisigState.Decode(data).Should().BeNull(); + } + } +} + +public static class TokenMetadataStateDecoderTests +{ + [TestFixture] + public sealed class Decode + { + [Test] + public void PinnedBorshValue_IsDecoded() + { + // Arrange + var data = new List(); + data.AddRange(Key(1).ToBytes()); + data.AddRange(Key(2).ToBytes()); + WriteString(data, "Name"); + WriteString(data, "SYM"); + WriteString(data, "https://example.test"); + WriteUInt32(data, 1); + WriteString(data, "kind"); + WriteString(data, "test"); + + // Act + var state = TokenMetadataState.Decode(data.ToArray()); + + // Assert + state.Should().NotBeNull(); + state!.UpdateAuthority.Should().Be(Key(1)); + state.Mint.Should().Be(Key(2)); + state.Name.Should().Be("Name"); + state.Symbol.Should().Be("SYM"); + state.Uri.Should().Be("https://example.test"); + state.AdditionalMetadata.Should().ContainSingle(); + state.AdditionalMetadata[0].Key.Should().Be("kind"); + state.AdditionalMetadata[0].Value.Should().Be("test"); + } + + [Test] + public void HugeAdditionalCount_IsRejectedBeforeAllocation() + { + // Arrange: two keys, three empty strings, then an impossible untrusted vector count. + var data = new byte[(PublicKey.Length * 2) + (sizeof(uint) * 4)]; + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(data.Length - sizeof(uint)), uint.MaxValue); + + // Act & Assert + TokenMetadataState.Decode(data).Should().BeNull(); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/TokenGroupTests.cs b/tests/SolSharp.Programs.Tests/TokenGroupTests.cs new file mode 100644 index 0000000..49406f6 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/TokenGroupTests.cs @@ -0,0 +1,168 @@ +using System.Buffers.Binary; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; +using static SolSharp.Programs.Tests.TokenGroupTestHelpers; + +namespace SolSharp.Programs.Tests; + +internal static class TokenGroupTestHelpers +{ + internal static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + internal static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + internal static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; + + internal static byte[] GroupData() + { + var data = new byte[TokenGroupState.Length]; + Key(1).CopyTo(data); + Key(2).CopyTo(data.AsSpan(PublicKey.Length)); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(PublicKey.Length * 2), 3UL); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan((PublicKey.Length * 2) + sizeof(ulong)), 4UL); + return data; + } + + internal static byte[] MemberData() + { + var data = new byte[TokenGroupMemberState.Length]; + Key(5).CopyTo(data); + Key(2).CopyTo(data.AsSpan(PublicKey.Length)); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(PublicKey.Length * 2), 6UL); + return data; + } +} + +public static class TokenGroupTests +{ + [TestFixture] + public sealed class InitializeTokenGroup + { + [Test] + public void MatchesPinnedTokenGroupInterface() + { + // Act + var instruction = Token2022Program.InitializeTokenGroup(Key(1), Key(2), Key(3), Key(4), 100); + + // Assert: the discriminator is a SHA-256 prefix pinned by spl-token-group-interface 0.7.2. + Hex(instruction).Should().Be( + "79716c2736330004" + + "0404040404040404040404040404040404040404040404040404040404040404" + + "6400000000000000"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(3), true, false)); + } + + [Test] + public void MaybeNullRejectsAmbiguousZeroAddress() + { + // Act + Action act = () => _ = Token2022Program.InitializeTokenGroup( + Key(1), Key(2), Key(3), default(PublicKey), 4); + + // Assert + act.Should().Throw().WithParameterName("updateAuthority"); + } + } + + [TestFixture] + public sealed class UpdateTokenGroupMaxSize + { + [Test] + public void MatchesPinnedTokenGroupInterface() => + Hex(Token2022Program.UpdateTokenGroupMaxSize(Key(1), Key(4), 200)) + .Should().Be("6c25ab8ff81e126ec800000000000000"); + + [Test] + public void SupportsAnotherInterfaceImplementation() + { + // Act + var instruction = Token2022Program.UpdateTokenGroupMaxSize(Key(1), Key(2), 3, Key(9)); + + // Assert + instruction.ProgramId.Should().Be(Key(9)); + } + } + + [TestFixture] + public sealed class UpdateTokenGroupAuthority + { + [Test] + public void MatchesPinnedTokenGroupInterface() => + Hex(Token2022Program.UpdateTokenGroupAuthority(Key(1), Key(4), null)) + .Should().Be("a1695801edddd8cb" + new string('0', PublicKey.Length * 2)); + } + + [TestFixture] + public sealed class InitializeTokenGroupMember + { + [Test] + public void MatchesPinnedTokenGroupInterface() + { + // Act + var instruction = Token2022Program.InitializeTokenGroupMember(Key(5), Key(6), Key(7), Key(1), Key(4)); + + // Assert + Hex(instruction).Should().Be("9820deb0dfed7486"); + Metas(instruction).Should().Equal( + (Key(5), false, true), + (Key(6), false, false), + (Key(7), true, false), + (Key(1), false, true), + (Key(4), true, false)); + } + } +} + +public static class TokenGroupStateTests +{ + [TestFixture] + public sealed class Decode + { + [Test] + public void MatchesPinnedPodLayout() + { + // Arrange + var data = GroupData(); + + // Act + var group = TokenGroupState.Decode(data); + + // Assert + group.Should().NotBeNull(); + group!.UpdateAuthority.Should().Be(Key(1)); + group.Mint.Should().Be(Key(2)); + group.Size.Should().Be(3); + group.MaximumSize.Should().Be(4); + TokenGroupState.Decode(data.AsSpan()[..^1]).Should().BeNull(); + } + } +} + +public static class TokenGroupMemberStateTests +{ + [TestFixture] + public sealed class Decode + { + [Test] + public void MatchesPinnedPodLayout() + { + // Arrange + var data = MemberData(); + + // Act + var member = TokenGroupMemberState.Decode(data); + + // Assert + member.Should().NotBeNull(); + member!.Mint.Should().Be(Key(5)); + member.Group.Should().Be(Key(2)); + member.MemberNumber.Should().Be(6); + TokenGroupMemberState.Decode(data.AsSpan()[..^1]).Should().BeNull(); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/TokenProgramOpsTests.cs b/tests/SolSharp.Programs.Tests/TokenProgramOpsTests.cs index 779a7c7..1b34bee 100644 --- a/tests/SolSharp.Programs.Tests/TokenProgramOpsTests.cs +++ b/tests/SolSharp.Programs.Tests/TokenProgramOpsTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using NUnit.Framework; +using SolSharp.Core.Constants; using SolSharp.Core.Primitives; namespace SolSharp.Programs.Tests; @@ -153,7 +154,7 @@ public void WithoutNewAuthority_PacksCompactNone() public void WithToken2022AuthorityType_EncodesItsWireValue() { // Act: change a Token-2022 mint's transfer-fee authority. - var token2022 = PublicKey.Parse(SolSharp.Core.Constants.SolanaProgramIds.Token2022Program); + var token2022 = PublicKey.Parse(SolanaProgramIds.Token2022Program); var ix = TokenProgram.SetAuthority(Pk(2), Pk(3), AuthorityType.TransferFeeConfig, Pk(4), token2022); // Assert @@ -161,6 +162,46 @@ public void WithToken2022AuthorityType_EncodesItsWireValue() DataHex(ix).Should().Be("0604010404040404040404040404040404040404040404040404040404040404040404"); } + [Test] + public void UndefinedAuthorityType_Throws() + { + // Act + Action act = () => _ = TokenProgram.SetAuthority(Pk(2), Pk(3), (AuthorityType)byte.MaxValue); + + // Assert + act.Should().Throw().WithParameterName("authorityType"); + } + + [TestCase(false)] + [TestCase(true)] + public void Token2022AuthorityWithClassicProgram_Throws(bool explicitProgram) + { + // Arrange + PublicKey? tokenProgram = explicitProgram ? TokenProgram.ProgramId : null; + + // Act + Action act = () => _ = TokenProgram.SetAuthority( + Pk(2), Pk(3), AuthorityType.TransferFeeConfig, Pk(4), tokenProgram); + + // Assert + act.Should().Throw().WithParameterName("authorityType"); + } + + [Test] + public void Token2022AuthorityWithCustomProgram_IsCallerOwned() + { + // Arrange + var customProgram = Pk(9); + + // Act + var instruction = TokenProgram.SetAuthority( + Pk(2), Pk(3), AuthorityType.TransferFeeConfig, Pk(4), customProgram); + + // Assert + instruction.ProgramId.Should().Be(customProgram); + instruction.Data[1].Should().Be((byte)AuthorityType.TransferFeeConfig); + } + // The numbering mirrors spl-token-2022's AuthorityType (interface/src/instruction.rs); a wrong // value here is a wire bug, so every variant is pinned. [TestCase(AuthorityType.MintTokens, 0)] @@ -287,4 +328,114 @@ public void InitializeMint_NoFreezeAuthority_UsesMinimalForm() "0006" + "0606060606060606060606060606060606060606060606060606060606060606" + "00"); } } + + [TestFixture] + public sealed class MultisigAuthority + { + private static readonly PublicKey[] Members = [Pk(7), Pk(8)]; + + [Test] + public void TransferChecked_MatchesOfficialMultisigLayout() + { + // Act + var ix = TokenProgram.TransferChecked( + Pk(2), Pk(3), Pk(4), Pk(6), 1000, 6, tokenProgram: null, multisigSigners: Members); + + // Assert + DataHex(ix).Should().Be("0ce80300000000000006"); + ix.Accounts.Should().HaveCount(6); + Check(ix.Accounts[0], Pk(2), signer: false, writable: true); + Check(ix.Accounts[1], Pk(3), signer: false, writable: false); + Check(ix.Accounts[2], Pk(4), signer: false, writable: true); + Check(ix.Accounts[3], Pk(6), signer: false, writable: false); + Check(ix.Accounts[4], Members[0], signer: true, writable: false); + Check(ix.Accounts[5], Members[1], signer: true, writable: false); + } + + [Test] + public void SetAuthority_Token2022_MatchesOfficialMultisigLayout() + { + // Arrange + var token2022 = PublicKey.Parse(SolanaProgramIds.Token2022Program); + + // Act + var ix = TokenProgram.SetAuthority( + Pk(2), Pk(6), AuthorityType.TransferFeeConfig, Pk(4), token2022, Members); + + // Assert + ix.ProgramId.Should().Be(token2022); + DataHex(ix).Should().Be("0604010404040404040404040404040404040404040404040404040404040404040404"); + CheckMultisig(ix, authorityIndex: 1, Pk(6)); + } + + [Test] + public void EveryAuthorityOperation_UsesNonSignerAuthorityThenMemberSigners() + { + // Act + (Instruction Instruction, int AuthorityIndex, PublicKey Authority)[] instructions = + [ + (TokenProgram.Transfer(Pk(2), Pk(3), Pk(6), 1, null, Members), 2, Pk(6)), + (TokenProgram.MintTo(Pk(2), Pk(3), Pk(6), 1, null, Members), 2, Pk(6)), + (TokenProgram.Burn(Pk(2), Pk(3), Pk(6), 1, null, Members), 2, Pk(6)), + (TokenProgram.Approve(Pk(2), Pk(3), Pk(6), 1, null, Members), 2, Pk(6)), + (TokenProgram.Revoke(Pk(2), Pk(6), null, Members), 1, Pk(6)), + (TokenProgram.CloseAccount(Pk(2), Pk(3), Pk(6), null, Members), 2, Pk(6)), + (TokenProgram.FreezeAccount(Pk(2), Pk(3), Pk(6), null, Members), 2, Pk(6)), + (TokenProgram.ThawAccount(Pk(2), Pk(3), Pk(6), null, Members), 2, Pk(6)), + (TokenProgram.ApproveChecked(Pk(2), Pk(3), Pk(4), Pk(6), 1, 6, null, Members), 3, Pk(6)), + (TokenProgram.MintToChecked(Pk(2), Pk(3), Pk(6), 1, 6, null, Members), 2, Pk(6)), + (TokenProgram.BurnChecked(Pk(2), Pk(3), Pk(6), 1, 6, null, Members), 2, Pk(6)) + ]; + + // Assert + foreach (var (instruction, authorityIndex, authority) in instructions) + CheckMultisig(instruction, authorityIndex, authority); + } + + [Test] + public void EmptyMemberList_Throws() + { + // Act + Action act = () => _ = TokenProgram.Transfer(Pk(2), Pk(3), Pk(6), 1, null, []); + + // Assert + act.Should().Throw().WithMessage("*at least one*"); + } + + [Test] + public void ElevenMembers_IsAccepted() + { + // Arrange + var members = Enumerable.Range(10, 11).Select(value => Pk((byte)value)).ToArray(); + + // Act + var instruction = TokenProgram.Transfer(Pk(2), Pk(3), Pk(6), 1, null, members); + + // Assert + instruction.Accounts.Should().HaveCount(14); + } + + [Test] + public void TwelveMembers_Throws() + { + // Arrange + var members = Enumerable.Range(10, 12).Select(value => Pk((byte)value)).ToArray(); + + // Act + Action act = () => _ = TokenProgram.Transfer(Pk(2), Pk(3), Pk(6), 1, null, members); + + // Assert + act.Should().Throw() + .WithParameterName("multisigSigners") + .WithMessage("*at most 11*12*"); + } + + private static void CheckMultisig(Instruction instruction, int authorityIndex, PublicKey authority) + { + instruction.Accounts.Should().HaveCount(authorityIndex + 1 + Members.Length); + Check(instruction.Accounts[authorityIndex], authority, signer: false, writable: false); + Check(instruction.Accounts[authorityIndex + 1], Members[0], signer: true, writable: false); + Check(instruction.Accounts[authorityIndex + 2], Members[1], signer: true, writable: false); + } + } } diff --git a/tests/SolSharp.Programs.Tests/TokenProgramParityTests.cs b/tests/SolSharp.Programs.Tests/TokenProgramParityTests.cs new file mode 100644 index 0000000..0e91183 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/TokenProgramParityTests.cs @@ -0,0 +1,301 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class TokenProgramParityTests +{ + private static readonly PublicKey Rent = PublicKey.Parse(Sysvars.Rent); + + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + private static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + private static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; + + [TestFixture] + public sealed class InitializeMint2 + { + [Test] + public void MatchesPinnedTokenInterface() + { + // Act + var instruction = TokenProgram.InitializeMint2(Key(1), 6, Key(2), Key(3)); + + // Assert + Hex(instruction).Should().Be( + "1406" + + "0202020202020202020202020202020202020202020202020202020202020202" + + "01" + + "0303030303030303030303030303030303030303030303030303030303030303"); + Metas(instruction).Should().Equal((Key(1), false, true)); + } + } + + [TestFixture] + public sealed class InitializeAccount2 + { + [Test] + public void MatchesPinnedTokenInterface() + { + // Act + var instruction = TokenProgram.InitializeAccount2(Key(1), Key(2), Key(3)); + + // Assert + Hex(instruction).Should().Be("10" + + "0303030303030303030303030303030303030303030303030303030303030303"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Rent, false, false)); + } + } + + [TestFixture] + public sealed class InitializeAccount3 + { + [Test] + public void MatchesPinnedTokenInterface() + { + // Act + var instruction = TokenProgram.InitializeAccount3(Key(1), Key(2), Key(3)); + + // Assert + Hex(instruction).Should().Be("12" + + "0303030303030303030303030303030303030303030303030303030303030303"); + Metas(instruction).Should().Equal((Key(1), false, true), (Key(2), false, false)); + } + } + + [TestFixture] + public sealed class InitializeMultisig + { + [Test] + public void MatchesPinnedTokenInterface() + { + // Arrange + PublicKey[] members = [Key(2), Key(3), Key(4)]; + + // Act + var instruction = TokenProgram.InitializeMultisig(Key(1), members, 2); + + // Assert + Hex(instruction).Should().Be("0202"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Rent, false, false), + (Key(2), false, false), + (Key(3), false, false), + (Key(4), false, false)); + } + + [TestCase(0, 1)] + [TestCase(12, 1)] + [TestCase(1, 0)] + [TestCase(1, 2)] + public void RejectsUnrepresentableThresholds(int signerCount, byte required) + { + // Arrange + var members = Enumerable.Range(0, signerCount).Select(index => Key((byte)(index + 2))).ToArray(); + + // Act + Action act = () => _ = TokenProgram.InitializeMultisig(Key(1), members, required); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class InitializeMultisig2 + { + [Test] + public void MatchesPinnedTokenInterface() + { + // Arrange + PublicKey[] members = [Key(2), Key(3), Key(4)]; + + // Act + var instruction = TokenProgram.InitializeMultisig2(Key(1), members, 2); + + // Assert + Hex(instruction).Should().Be("1302"); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, false), + (Key(3), false, false), + (Key(4), false, false)); + } + } + + [TestFixture] + public sealed class SyncNativeWithRentSysvar + { + [Test] + public void MatchesPinnedTokenInterface() + { + // Act + var instruction = TokenProgram.SyncNativeWithRentSysvar(Key(1)); + + // Assert + Hex(instruction).Should().Be("11"); + Metas(instruction).Should().Equal((Key(1), false, true), (Rent, false, false)); + } + } + + [TestFixture] + public sealed class GetAccountDataSize + { + [Test] + public void MatchesPinnedTokenInterface() + { + // Act + var instruction = TokenProgram.GetAccountDataSize(Key(2)); + + // Assert + Hex(instruction).Should().Be("15"); + Metas(instruction).Should().Equal((Key(2), false, false)); + } + } + + [TestFixture] + public sealed class InitializeImmutableOwner + { + [Test] + public void MatchesPinnedTokenInterface() + { + // Act + var instruction = TokenProgram.InitializeImmutableOwner(Key(1)); + + // Assert + Hex(instruction).Should().Be("16"); + Metas(instruction).Should().Equal((Key(1), false, true)); + } + } + + [TestFixture] + public sealed class AmountToUiAmount + { + [Test] + public void MatchesPinnedTokenInterface() => + Hex(TokenProgram.AmountToUiAmount(Key(2), 1_000)).Should().Be("17e803000000000000"); + } + + [TestFixture] + public sealed class UiAmountToAmount + { + [Test] + public void MatchesPinnedTokenInterface() => + Hex(TokenProgram.UiAmountToAmount(Key(2), "1.25")).Should().Be("18312e3235"); + + [Test] + public void RejectsInvalidUnicode() + { + // Act + Action act = () => _ = TokenProgram.UiAmountToAmount(Key(1), "\ud800"); + + // Assert + act.Should().Throw().WithParameterName("uiAmount"); + } + } + + [TestFixture] + public sealed class WithdrawExcessLamports + { + [Test] + public void MatchesPinnedTokenInterface() + { + // Act + var instruction = TokenProgram.WithdrawExcessLamports(Key(1), Key(2), Key(3)); + + // Assert + Hex(instruction).Should().Be("26"); + instruction.ProgramId.Should().Be(Token2022Program.ProgramId); + Metas(instruction).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (Key(3), true, false)); + } + } + + [TestFixture] + public sealed class UnwrapLamports + { + [Test] + public void MatchesPinnedTokenInterface() + { + // Act + var unwrapAll = TokenProgram.UnwrapLamports(Key(1), Key(2), Key(3)); + var unwrapSome = TokenProgram.UnwrapLamports(Key(1), Key(2), Key(3), 42); + + // Assert + Hex(unwrapAll).Should().Be("2d00"); + Hex(unwrapSome).Should().Be("2d012a00000000000000"); + unwrapAll.ProgramId.Should().Be(Token2022Program.ProgramId); + unwrapSome.ProgramId.Should().Be(Token2022Program.ProgramId); + } + } + + [TestFixture] + public sealed class Batch + { + [Test] + public void MatchesPinnedTokenInterfaceEncoding() + { + // Arrange + var sync = TokenProgram.SyncNative(Key(1), Token2022Program.ProgramId); + var transfer = TokenProgram.Transfer(Key(2), Key(3), Key(4), 5, Token2022Program.ProgramId); + + // Act + var batch = TokenProgram.Batch([sync, transfer]); + + // Assert + Hex(batch).Should().Be("ff0101110309030500000000000000"); + Metas(batch).Should().Equal( + (Key(1), false, true), + (Key(2), false, true), + (Key(3), false, true), + (Key(4), true, false)); + batch.ProgramId.Should().Be(Token2022Program.ProgramId); + } + + [Test] + public void RejectsAnotherProgram() + { + // Arrange + var systemInstruction = SystemProgram.Transfer(Key(1), Key(2), 1); + + // Act + Action act = () => _ = TokenProgram.Batch([systemInstruction]); + + // Assert + act.Should().Throw().WithParameterName("instructions"); + } + + [Test] + public void RejectsEmptyDataEmptyBatchAndNestedBatches() + { + // Arrange + var inner = TokenProgram.Batch([TokenProgram.SyncNative(Key(1), Token2022Program.ProgramId)]); + var emptyData = new Instruction + { + ProgramId = Token2022Program.ProgramId, + Accounts = [], + Data = [] + }; + + // Act + Action empty = () => _ = TokenProgram.Batch([]); + Action emptyInnerData = () => _ = TokenProgram.Batch([emptyData]); + Action nested = () => _ = TokenProgram.Batch([inner]); + + // Assert + empty.Should().Throw().WithParameterName("instructions"); + emptyInnerData.Should().Throw().WithParameterName("instructions"); + nested.Should().Throw().WithParameterName("instructions"); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/TransactionBuilderTests.cs b/tests/SolSharp.Programs.Tests/TransactionBuilderTests.cs index 9c4ee3a..ed4d1cc 100644 --- a/tests/SolSharp.Programs.Tests/TransactionBuilderTests.cs +++ b/tests/SolSharp.Programs.Tests/TransactionBuilderTests.cs @@ -84,6 +84,22 @@ public void WithoutFeePayerOrSigner_Throws() // Assert act.Should().Throw(); } + + [Test] + public void NullFirstSigner_ThrowsDocumentedArgumentNullException() + { + // Arrange + var builder = new TransactionBuilder() + .SetRecentBlockhash(Blockhash) + .AddInstruction(SystemProgram.Transfer(new PublicKey(Fill(1)), new PublicKey(Fill(2)), 1)); + + // Act + Action act = () => builder.Build(null!); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be("signers"); + } } [TestFixture] @@ -137,6 +153,73 @@ public void AppendsInOrder() instructions[0].Data.Should().Equal(first.Data); instructions[1].Data.Should().Equal(second.Data); } + + [Test] + public void NullElement_ThrowsDocumentedArgumentNullException() + { + // Arrange + var builder = new TransactionBuilder(); + + // Act + Action act = () => builder.AddInstructions([null!]); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be("instructions"); + } + } + + [TestFixture] + public sealed class SetRecentBlockhash + { + [Test] + public void TypedHash_BuildsWithSameWireValue() + { + // Arrange + using var payer = Keypair.FromSeed(Fill(1)); + + // Act + var message = new TransactionBuilder() + .SetFeePayer(payer.PublicKey) + .SetRecentBlockhash(new Hash(Blockhash)) + .AddInstruction(SystemProgram.Transfer(payer.PublicKey, new PublicKey(Fill(2)), 1)) + .BuildMessage(); + + // Assert + message.RecentBlockhash.Should().Be(Blockhash); + } + } + + [TestFixture] + public sealed class InputValidation + { + [Test] + public void NullRecentBlockhash_ThrowsAtSetter() + { + // Arrange + var builder = new TransactionBuilder(); + + // Act + Action act = () => builder.SetRecentBlockhash(null!); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be("recentBlockhash"); + } + + [Test] + public void NullLookupTable_ThrowsAtSetter() + { + // Arrange + var builder = new TransactionBuilder(); + + // Act + Action act = () => builder.SetAddressLookupTables([null!]); + + // Assert + act.Should().Throw() + .Which.ParamName.Should().Be("lookupTables"); + } } [TestFixture] @@ -171,6 +254,65 @@ public void CompilesTheMessageInsideTheSignedV0Transaction_MatchesSolders() [TestFixture] public sealed class SetDurableNonce { + [Test] + public void TypedNonce_BuildsWithSameWireValue() + { + // Arrange + using var payer = Keypair.FromSeed(Fill(1)); + var nonceAccount = new PublicKey(Fill(5)); + + // Act + var message = new TransactionBuilder() + .SetFeePayer(payer.PublicKey) + .SetDurableNonce(nonceAccount, payer.PublicKey, new Hash(Blockhash)) + .BuildMessage(); + + // Assert + message.RecentBlockhash.Should().Be(Blockhash); + } + + [Test] + public void WithoutOtherInstructions_BuildsNonceAdvanceOnlyMessage() + { + // Arrange + using var payer = Keypair.FromSeed(Fill(1)); + var nonceAccount = new PublicKey(Fill(5)); + + // Act + var message = new TransactionBuilder() + .SetFeePayer(payer.PublicKey) + .SetDurableNonce(nonceAccount, payer.PublicKey, Blockhash) + .BuildMessage(); + + // Assert + message.RecentBlockhash.Should().Be(Blockhash); + var instruction = message.DecompileInstructions([]).Should().ContainSingle().Subject; + instruction.ProgramId.Should().Be(SystemProgram.ProgramId); + instruction.Data.Should().Equal(Convert.FromHexString("04000000")); + instruction.Accounts[0].PublicKey.Should().Be(nonceAccount); + } + + [Test] + public void WithoutOtherInstructions_BuildsNonceAdvanceOnlyV0Message() + { + // Arrange + using var payer = Keypair.FromSeed(Fill(1)); + var nonceAccount = new PublicKey(Fill(5)); + + // Act + var message = new TransactionBuilder() + .SetFeePayer(payer.PublicKey) + .SetDurableNonce(nonceAccount, payer.PublicKey, Blockhash) + .BuildMessageV0(); + + // Assert + message.RecentBlockhash.Should().Be(Blockhash); + var instruction = message.DecompileInstructions([]).Should().ContainSingle().Subject; + instruction.ProgramId.Should().Be(SystemProgram.ProgramId); + instruction.Data.Should().Equal(Convert.FromHexString("04000000")); + instruction.Accounts[0].PublicKey.Should().Be(nonceAccount); + } + [Test] public void PrependsAdvanceNonce_AndUsesNonceAsTheBlockhash() { diff --git a/tests/SolSharp.Programs.Tests/TransactionDeserializeTests.cs b/tests/SolSharp.Programs.Tests/TransactionDeserializeTests.cs index 9a48522..861fe7e 100644 --- a/tests/SolSharp.Programs.Tests/TransactionDeserializeTests.cs +++ b/tests/SolSharp.Programs.Tests/TransactionDeserializeTests.cs @@ -34,6 +34,7 @@ public void LegacyTransfer_RoundTripsAndParsesFields() // Assert transaction.Serialize().Should().Equal(bytes); transaction.Message.Should().BeOfType(); + transaction.Version.Should().Be(TransactionVersion.Legacy); transaction.Message.RequiredSignatures.Should().Be(1); transaction.Message.AccountKeys.Should().HaveCount(3); } @@ -50,10 +51,26 @@ public void V0Transfer_RoundTripsAndIsVersioned() // Assert transaction.Serialize().Should().Equal(bytes); transaction.Message.Should().BeOfType(); + transaction.Version.Should().Be(TransactionVersion.V0); var message = (MessageV0)transaction.Message; message.AddressTableLookups.Should().ContainSingle(); - message.AddressTableLookups[0].WritableIndexes.Should().Equal((byte)0); + message.AddressTableLookups[0].WritableIndexes.Should().Equal(0); + } + + [TestCase(SignedTransferHex)] + [TestCase(SignedV0Hex)] + public void MessageMutation_DoesNotChangeReserializedBytes(string hex) + { + // Arrange + var bytes = Convert.FromHexString(hex); + var transaction = Transaction.Deserialize(bytes); + + // Act + transaction.Message.Instructions[0].Data[0] ^= 0xFF; + + // Assert + transaction.Serialize().Should().Equal(bytes); } [Test] @@ -69,6 +86,20 @@ public void TruncatedData_ThrowsFormatException() act.Should().Throw(); } + [Test] + public void HighBitDiscriminator_IsRejectedBeforeCompactSignatureDecoding() + { + // Arrange: SIMD-0385 reserves a top-bit discriminator for message-first transactions. + // Only 0x81 is V1; 0xFF must not be interpreted as a multi-byte compact signature count. + byte[] data = [0xff, 0xff, 0x03]; + + // Act + Action act = () => Transaction.Deserialize(data); + + // Assert + act.Should().Throw().WithMessage("*Invalid transaction discriminator 0xFF*"); + } + [Test] public void FewerSignatureSlotsThanRequiredSigners_ThrowsFormatException() { @@ -134,5 +165,19 @@ public void TrySerialize_TooSmallBuffer_ReturnsFalse() ok.Should().BeFalse(); written.Should().Be(0); } + + [TestCase(SignedTransferHex)] + [TestCase(SignedV0Hex)] + public void TrailingByte_ThrowsFormatException(string hex) + { + // Arrange + byte[] bytes = [.. Convert.FromHexString(hex), 0xAA]; + + // Act + Action act = () => Transaction.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*trailing byte(s)*"); + } } } diff --git a/tests/SolSharp.Programs.Tests/TransactionMessageHashTests.cs b/tests/SolSharp.Programs.Tests/TransactionMessageHashTests.cs new file mode 100644 index 0000000..aa3ccae --- /dev/null +++ b/tests/SolSharp.Programs.Tests/TransactionMessageHashTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class TransactionMessageHashTests +{ + [TestFixture] + public sealed class Compute + { + [Test] + public void PinnedSolanaSdkVector_MatchesDomainSeparatedBlake3Hash() + { + // Arrange: solana-sdk/message/src/legacy.rs test_message_hash at the pinned commit. + var program0 = PublicKey.Parse("4uQeVj5tqViQh7yWWGStvkEG1Zmhx6uasJtWCJziofM"); + var program1 = PublicKey.Parse("8opHzTAnfzRpPEx21XtnrVTX28YQuCpAjcn1PczScKh"); + var id0 = PublicKey.Parse("CiDwVBFgWV9E5MvXWoLgnEgn2hK7rJikbvfWavzAQz3"); + var id1 = PublicKey.Parse("GcdayuLaLyrdmUu324nahyv33G5poQdLUEZ1nEytDeP"); + var id2 = PublicKey.Parse("LX3EUdRUBUa3TbsYXLEUdj9J3prXkWXvLYSWyYyc2Jj"); + var id3 = PublicKey.Parse("QRSsyMWN1yHT9ir42bgNZUNZ4PdEhcSWCrL2AryKpy5"); + var instructions = new[] + { + new Instruction { ProgramId = program0, Accounts = [AccountMeta.Writable(id0)], Data = new byte[4] }, + new Instruction { ProgramId = program0, Accounts = [AccountMeta.WritableSigner(id1)], Data = new byte[4] }, + new Instruction { ProgramId = program1, Accounts = [AccountMeta.Readonly(id2)], Data = new byte[4] }, + new Instruction { ProgramId = program1, Accounts = [AccountMeta.ReadonlySigner(id3)], Data = new byte[4] } + }; + var message = Message.Compile(id1, default(Hash), instructions); + + // Act + var fromMessage = TransactionMessageHash.Compute(message); + var fromBytes = TransactionMessageHash.Compute(message.Serialize()); + + // Assert + fromMessage.Should().Be(Hash.Parse("7VWCF4quo2CcWQFNUayZiorxpiR5ix8YzLebrXKf3fMF")); + fromBytes.Should().Be(fromMessage); + } + + [Test] + public void NullMessage_Throws() + { + // Act + Action act = () => TransactionMessageHash.Compute((ITransactionMessage)null!); + + // Assert + act.Should().Throw().WithParameterName("message"); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/TransactionTests.cs b/tests/SolSharp.Programs.Tests/TransactionTests.cs index 1578968..fb120eb 100644 --- a/tests/SolSharp.Programs.Tests/TransactionTests.cs +++ b/tests/SolSharp.Programs.Tests/TransactionTests.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using FluentAssertions; using NUnit.Framework; using SolSharp.Core.Constants; @@ -22,6 +23,19 @@ public static class TransactionTests private static byte[] Fill(byte value) => [.. Enumerable.Repeat(value, PublicKey.Length)]; + private sealed class TestSigner(PublicKey publicKey, byte[]? signature) : ISigner + { + public PublicKey PublicKey { get; } = publicKey; + + public int CallCount { get; private set; } + + byte[] ISigner.Sign(ReadOnlySpan message) + { + CallCount++; + return signature!; + } + } + private static Transaction BuildTransfer(out Keypair payer) { payer = Keypair.FromSeed(Fill(1)); @@ -39,6 +53,20 @@ private static Transaction BuildTransfer(out Keypair payer) return Transaction.Create(message); } + private static Transaction BuildTwoSignerTransaction(out Keypair payer, out Keypair cosigner) + { + payer = Keypair.FromSeed(Fill(1)); + cosigner = Keypair.FromSeed(Fill(2)); + var instruction = new Instruction + { + ProgramId = new PublicKey(Fill(9)), + Accounts = [AccountMeta.ReadonlySigner(cosigner.PublicKey)], + Data = [7] + }; + var message = Message.Compile(payer.PublicKey, new Hash(Fill(8)), [instruction]); + return Transaction.Create(message); + } + [TestFixture] public sealed class Sign { @@ -63,8 +91,9 @@ public void NonRequiredSigner_Throws() { // Arrange var transaction = BuildTransfer(out var payer); - using var stranger = Keypair.Generate(); + var stranger = Keypair.Generate(); using (payer) + using (stranger) { // Act Action act = () => transaction.Sign(stranger); @@ -73,6 +102,153 @@ public void NonRequiredSigner_Throws() act.Should().Throw(); } } + + [Test] + public void LaterNonRequiredSigner_IsRejectedBeforeAnySignerIsCalled() + { + // Arrange + var transaction = BuildTransfer(out var payer); + using (payer) + { + var firstSigner = new TestSigner(payer.PublicKey, new byte[Transaction.SignatureLength]); + var stranger = new TestSigner(new PublicKey(Fill(9)), new byte[Transaction.SignatureLength]); + + // Act + Action act = () => transaction.Sign(firstSigner, stranger); + + // Assert + act.Should().Throw(); + firstSigner.CallCount.Should().Be(0); + stranger.CallCount.Should().Be(0); + } + } + + [Test] + public void NullSignerElement_Throws() + { + // Arrange + var transaction = BuildTransfer(out var payer); + using (payer) + { + ISigner[] signers = [null!]; + + // Act + Action act = () => transaction.Sign(signers); + + // Assert + act.Should().Throw().WithParameterName(nameof(signers)); + } + } + + [TestCase(63)] + [TestCase(65)] + public void InvalidSignatureLength_Throws(int length) + { + // Arrange + var transaction = BuildTransfer(out var payer); + using (payer) + { + var signer = new TestSigner(payer.PublicKey, new byte[length]); + + // Act + Action act = () => transaction.Sign(signer); + + // Assert + act.Should().Throw().WithMessage("*64-byte*"); + } + } + + [Test] + public void NullSignature_Throws() + { + // Arrange + var transaction = BuildTransfer(out var payer); + using (payer) + { + var signer = new TestSigner(payer.PublicKey, signature: null); + + // Act + Action act = () => transaction.Sign(signer); + + // Assert + act.Should().Throw().WithMessage("*64-byte*"); + } + } + + [Test] + public void LaterInvalidSignature_DoesNotCommitEarlierSignature() + { + // Arrange + var payer = new PublicKey(Fill(1)); + var second = new PublicKey(Fill(2)); + var instruction = new Instruction + { + ProgramId = new PublicKey(Fill(9)), + Accounts = [AccountMeta.ReadonlySigner(second)], + Data = [7] + }; + var message = Message.Compile(payer, new PublicKey(Fill(8)).ToString(), [instruction]); + var transaction = Transaction.Create(message); + var firstSigner = new TestSigner(payer, [.. Enumerable.Repeat((byte)0xAB, Transaction.SignatureLength)]); + var invalidSecondSigner = new TestSigner(second, new byte[Transaction.SignatureLength - 1]); + + // Act + Action act = () => transaction.Sign(firstSigner, invalidSecondSigner); + + // Assert + act.Should().Throw(); + transaction.Serialize().Skip(1).Take(2 * Transaction.SignatureLength).Should().OnlyContain(value => value == 0); + } + + [Test] + public void MutatingReturnedSignature_DoesNotMutateTransaction() + { + // Arrange + var transaction = BuildTransfer(out var payer); + using (payer) + { + byte[] signature = [.. Enumerable.Repeat((byte)0xAB, Transaction.SignatureLength)]; + var signer = new TestSigner(payer.PublicKey, signature); + transaction.Sign(signer); + + // Act + signature[0] = 0; + + // Assert + transaction.Serialize()[1].Should().Be(0xAB); + } + } + + [Test] + public void SignerSlotsRemainBoundToCapturedMessageAfterAccountKeyMutation() + { + // Arrange + var payer = new PublicKey(Fill(1)); + var second = new PublicKey(Fill(2)); + var stranger = new PublicKey(Fill(3)); + var instruction = new Instruction + { + ProgramId = new PublicKey(Fill(9)), + Accounts = [AccountMeta.ReadonlySigner(second)], + Data = [7] + }; + var message = Message.Compile(payer, new PublicKey(Fill(8)).ToString(), [instruction]); + var transaction = Transaction.Create(message); + var signature = new byte[Transaction.SignatureLength]; + var payerSigner = new TestSigner(payer, signature); + var secondSigner = new TestSigner(second, signature); + var strangerSigner = new TestSigner(stranger, signature); + transaction.Sign(payerSigner); + + // Act + ((List)message.AccountKeys)[1] = stranger; + Action strangerAct = () => transaction.Sign(strangerSigner); + Action originalSignerAct = () => transaction.Sign(secondSigner); + + // Assert + strangerAct.Should().Throw(); + originalSignerAct.Should().NotThrow(); + } } [TestFixture] @@ -93,5 +269,167 @@ public void Unsigned_LeavesSignatureSlotZeroed() bytes.Skip(1).Take(Transaction.SignatureLength).Should().OnlyContain(b => b == 0); } } + + [Test] + public void SignedTransaction_IsStableAfterMessageMutation() + { + // Arrange + var transaction = BuildTransfer(out var payer); + using (payer) + { + transaction.Sign(payer); + var before = transaction.Serialize(); + + // Act + transaction.Message.Instructions[0].Data[0] ^= 0xFF; + + // Assert + transaction.Serialize().Should().Equal(before); + transaction.GetSerializedLength().Should().Be(before.Length); + } + } + } + + [TestFixture] + public sealed class VerifySignaturesWithResults + { + [Test] + public void UnsignedTransaction_ExposesOrderedZeroSignatureSlots() + { + // Arrange + var transaction = BuildTwoSignerTransaction(out var payer, out var cosigner); + using (payer) + using (cosigner) + { + // Act + var results = transaction.VerifySignaturesWithResults(); + var absent = default(Signature); + + // Assert + transaction.RequiredSignerKeys.Should().Equal(payer.PublicKey, cosigner.PublicKey); + transaction.Signatures.Should().Equal(absent, absent); + transaction.IsFullySigned.Should().BeFalse(); + results.Should().Equal(false, false); + transaction.VerifySignatures().Should().BeFalse(); + } + } + } + + [TestFixture] + public sealed class PartialSign + { + [Test] + public void PartialSign_FillsOnlyMatchingSlotAndRetainsItForSecondStage() + { + // Arrange + var transaction = BuildTwoSignerTransaction(out var payer, out var cosigner); + using (payer) + using (cosigner) + { + var message = transaction.GetMessageBytes(); + + // Act + transaction.PartialSign(payer); + + // Assert + transaction.GetSignature(payer.PublicKey).Should().Be(payer.SignSignature(message)); + transaction.GetSignature(cosigner.PublicKey).Should().Be(default(Signature)); + transaction.VerifySignaturesWithResults().Should().Equal(true, false); + transaction.IsFullySigned.Should().BeFalse(); + } + } + } + + [TestFixture] + public sealed class SignAll + { + [Test] + public void SignAll_RequiresEverySlotButPreservesSuccessfulPartialWork() + { + // Arrange + var transaction = BuildTwoSignerTransaction(out var payer, out var cosigner); + using (payer) + using (cosigner) + { + // Act + Action incomplete = () => transaction.SignAll(payer); + + // Assert + incomplete.Should().Throw().WithMessage("*not fully signed*"); + transaction.GetSignature(payer.PublicKey).Should().NotBe(default(Signature)); + + transaction.SignAll(cosigner); + transaction.IsFullySigned.Should().BeTrue(); + transaction.VerifySignatures().Should().BeTrue(); + } + } + } + + [TestFixture] + public sealed class AddSignature + { + [Test] + public void AddSignature_VerifiesAndPlacesExternalSignature() + { + // Arrange + var transaction = BuildTwoSignerTransaction(out var payer, out var cosigner); + using (payer) + using (cosigner) + { + var message = transaction.GetMessageBytes(); + var external = cosigner.SignSignature(message); + + // Act + transaction.PartialSign(payer).AddSignature(cosigner.PublicKey, external); + + // Assert + transaction.GetSignature(cosigner.PublicKey).Should().Be(external); + transaction.IsFullySigned.Should().BeTrue(); + transaction.VerifySignatures().Should().BeTrue(); + } + } + + [Test] + public void AddSignature_RejectsSignatureForDifferentMessage() + { + // Arrange + var transaction = BuildTwoSignerTransaction(out var payer, out var cosigner); + using (payer) + using (cosigner) + { + var wrongSignature = cosigner.SignSignature("different message"u8); + + // Act + Action act = () => transaction.AddSignature(cosigner.PublicKey, wrongSignature); + + // Assert + act.Should().Throw(); + transaction.GetSignature(cosigner.PublicKey).Should().Be(default(Signature)); + } + } + } + + [TestFixture] + public sealed class VerifyAndHashMessage + { + [Test] + public void VerifyAndHashMessage_MatchesStandaloneHash() + { + // Arrange + var transaction = BuildTwoSignerTransaction(out var payer, out var cosigner); + using (payer) + using (cosigner) + { + transaction.SignAll(payer, cosigner); + var expected = TransactionMessageHash.Compute(transaction.GetMessageBytes()); + + // Act + var actual = transaction.VerifyAndHashMessage(); + + // Assert + actual.Should().Be(expected); + transaction.GetMessageHash().Should().Be(expected); + } + } } } diff --git a/tests/SolSharp.Programs.Tests/TransactionV1Tests.cs b/tests/SolSharp.Programs.Tests/TransactionV1Tests.cs new file mode 100644 index 0000000..aa71f52 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/TransactionV1Tests.cs @@ -0,0 +1,365 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; +using SolSharp.Wallet; + +namespace SolSharp.Programs.Tests; + +public static class TransactionV1Tests +{ + private static PublicKey Pk(byte value) => new(Fill(value)); + + private static byte[] Fill(byte value) => [.. Enumerable.Repeat(value, PublicKey.Length)]; + + // solana-sdk ec7a0467e268774b724d55120ad952b518f27d64 + // message/src/versions/v1/message.rs::byte_layout_without_config, version-prefixed for a transaction. + private static byte[] UpstreamMessage() + { + var bytes = new List { MessageV1.VersionPrefix, 1, 0, 0 }; + bytes.AddRange(new byte[sizeof(uint)]); + bytes.AddRange(Fill(0xAB)); + bytes.Add(1); + bytes.Add(2); + bytes.AddRange(Fill(1)); + bytes.AddRange(Fill(2)); + bytes.Add(1); + bytes.Add(1); + bytes.AddRange([2, 0]); + bytes.Add(0); + bytes.AddRange([0xDE, 0xAD]); + return [.. bytes]; + } + + private static MessageV1 SimpleMessage(int dataLength = 1) + { + var instruction = new Instruction + { + ProgramId = Pk(2), + Accounts = [AccountMeta.WritableSigner(Pk(1))], + Data = new byte[dataLength] + }; + return MessageV1.Compile(Pk(1), new Hash(Fill(0xAB)), [instruction]); + } + + [TestFixture] + public sealed class Create + { + [Test] + public void UnsignedTransaction_IsMessageThenFixedSignatures_MatchingPinnedUpstream() + { + // Arrange + var messageBytes = UpstreamMessage(); + var message = MessageV1.Deserialize(messageBytes); + + // Act + var transaction = Transaction.Create(message); + var bytes = transaction.Serialize(); + + // Assert + transaction.Version.Should().Be(TransactionVersion.V1); + transaction.GetSerializedLength().Should().Be(messageBytes.Length + Transaction.SignatureLength); + bytes[..messageBytes.Length].Should().Equal(messageBytes); + bytes[messageBytes.Length..].Should().OnlyContain(value => value == 0); + bytes[0].Should().Be(MessageV1.VersionPrefix); + } + } + + [TestFixture] + public sealed class Sign + { + [Test] + public void SigningPlacesEd25519SignatureAfterExactMessageBytes() + { + // Arrange + using var payer = Keypair.FromSeed(Fill(7)); + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = [AccountMeta.WritableSigner(payer.PublicKey)], + Data = [1, 2, 3] + }; + var message = MessageV1.Compile( + payer.PublicKey, + new Hash(Fill(8)), + [instruction], + new TransactionConfigV1 + { + ComputeUnitLimit = 200_000, + LoadedAccountsDataSizeLimit = 1_000_000 + }); + var signableBytes = message.Serialize(); + + // Act + var bytes = Transaction.Create(message).Sign(payer).Serialize(); + + // Assert + bytes[..signableBytes.Length].Should().Equal(signableBytes); + bytes.Length.Should().Be(signableBytes.Length + Signature.Length); + payer.PublicKey.Verify(signableBytes, bytes.AsSpan(signableBytes.Length)).Should().BeTrue(); + } + } + + [TestFixture] + public sealed class TrySerialize + { + [Test] + public void TrySerialize_MatchesAllocatingPathAndExactLength() + { + // Arrange + var transaction = Transaction.Create(SimpleMessage()); + var expected = transaction.Serialize(); + var destination = new byte[transaction.GetSerializedLength()]; + + // Act + var success = transaction.TrySerialize(destination, out var written); + + // Assert + success.Should().BeTrue(); + written.Should().Be(expected.Length); + destination.Should().Equal(expected); + } + + [Test] + public void TrySerialize_ShortSpanReturnsFalse() + { + // Arrange + var transaction = Transaction.Create(SimpleMessage()); + + // Act + var success = transaction.TrySerialize(new byte[transaction.GetSerializedLength() - 1], out var written); + + // Assert + success.Should().BeFalse(); + written.Should().Be(0); + } + } + + [TestFixture] + public sealed class Serialize + { + [Test] + public void DeserializedMessageMutation_DoesNotChangeCapturedWireBytes() + { + // Arrange + byte[] bytes = [.. UpstreamMessage(), .. new byte[Transaction.SignatureLength]]; + var transaction = Transaction.Deserialize(bytes); + + // Act + transaction.Message.Instructions[0].Data[0] ^= 0xFF; + + // Assert + transaction.Serialize().Should().Equal(bytes); + } + + [TestCase(3_921, MessageV1.MaxTransactionSize)] + [TestCase(3_922, MessageV1.MaxTransactionSize + 1)] + public void CodecRoundTripsAtAndAboveRpcRuntimeSizeBoundary(int dataLength, int expectedSize) + { + // Arrange: pinned upstream wincode intentionally round-trips both 4096 and 4097 bytes; + // packet/RPC admission, not this codec, applies MAX_TRANSACTION_SIZE. + var transaction = Transaction.Create(SimpleMessage(dataLength)); + + // Act + var bytes = transaction.Serialize(); + var parsed = Transaction.Deserialize(bytes); + + // Assert + bytes.Should().HaveCount(expectedSize); + parsed.Serialize().Should().Equal(bytes); + } + } + + [TestFixture] + public sealed class Deserialize + { + [Test] + public void UnsignedTransaction_RoundTripsWithoutSignatureCountPrefix() + { + // Arrange + byte[] bytes = [.. UpstreamMessage(), .. new byte[Transaction.SignatureLength]]; + + // Act + var transaction = Transaction.Deserialize(bytes); + + // Assert + transaction.Version.Should().Be(TransactionVersion.V1); + transaction.Message.Should().BeOfType(); + transaction.Serialize().Should().Equal(bytes); + } + + [TestCase(0x80)] + [TestCase(0x82)] + [TestCase(0xFF)] + public void UnknownHighBitTransactionDiscriminator_Throws(byte discriminator) + { + // Arrange + byte[] bytes = [discriminator]; + + // Act + Action act = () => Transaction.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*transaction discriminator*"); + } + + [Test] + public void V1MessageInsideLegacyEnvelope_Throws() + { + // Arrange: zero legacy signatures followed by a forbidden V1 message. + byte[] bytes = [0, .. UpstreamMessage(), .. new byte[Transaction.SignatureLength]]; + + // Act + Action act = () => Transaction.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*Invalid message version byte 0x81*"); + } + + [Test] + public void MissingFixedSignature_Throws() + { + // Arrange + var bytes = UpstreamMessage(); + + // Act + Action act = () => Transaction.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*requires 1 signature slot(s)*0 byte(s) remain*"); + } + + [Test] + public void ExtraFixedSignatureByte_Throws() + { + // Arrange + byte[] bytes = [.. UpstreamMessage(), .. new byte[Transaction.SignatureLength + 1]]; + + // Act + Action act = () => Transaction.Deserialize(bytes); + + // Assert + act.Should().Throw().WithMessage("*64 bytes*65 byte(s) remain*"); + } + + [Test] + public void TruncatedV1Message_ThrowsDocumentedFormatException() + { + // Arrange + byte[] bytes = [MessageV1.VersionPrefix, 1, 0]; + + // Act + Action act = () => Transaction.Deserialize(bytes); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class BuildMessageV1 + { + [Test] + public void BuildMessageV1_AppliesTypedLifetimeAndAllInlineConfig() + { + // Arrange + var hash = new Hash(Fill(8)); + var config = new TransactionConfigV1 + { + PriorityFee = 500, + ComputeUnitLimit = 200_000, + LoadedAccountsDataSizeLimit = 1_000_000, + HeapSize = 65_536 + }; + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = [AccountMeta.WritableSigner(Pk(1)), AccountMeta.Readonly(Pk(2))], + Data = [7] + }; + + // Act + var message = new TransactionBuilder() + .SetFeePayer(Pk(1)) + .SetRecentBlockhash(hash) + .SetV1Config(config) + .AddInstruction(instruction) + .BuildMessageV1(); + + // Assert + message.LifetimeSpecifier.Should().Be(hash); + message.Config.Should().Be(config); + message.DecompileInstructions().Should().ContainSingle(); + message.Serialize()[0].Should().Be(MessageV1.VersionPrefix); + } + + [Test] + public void AddressLookupTables_AreRejectedForV1() + { + // Arrange + var builder = new TransactionBuilder() + .SetFeePayer(Pk(1)) + .SetRecentBlockhash(new Hash(Fill(8))) + .SetAddressLookupTables(new AddressLookupTableAccount(Pk(5), [Pk(2)])) + .AddInstruction(new Instruction { ProgramId = Pk(9), Accounts = [], Data = [] }); + + // Act + Action act = () => builder.BuildMessageV1(); + + // Assert + act.Should().Throw().WithMessage("*do not support address lookup tables*"); + } + } + + [TestFixture] + public sealed class BuildV1 + { + [Test] + public void BuildV1_InfersFeePayerAndSignsMessageFirstWire() + { + // Arrange + using var payer = Keypair.FromSeed(Fill(7)); + var instruction = new Instruction + { + ProgramId = Pk(9), + Accounts = [AccountMeta.WritableSigner(payer.PublicKey)], + Data = [7] + }; + + // Act + var transaction = new TransactionBuilder() + .SetRecentBlockhash(new Hash(Fill(8))) + .SetV1Config(new TransactionConfigV1 + { + ComputeUnitLimit = 200_000, + LoadedAccountsDataSizeLimit = 1_000_000 + }) + .AddInstruction(instruction) + .BuildV1(payer); + var bytes = transaction.Serialize(); + var messageBytes = transaction.Message.Serialize(); + + // Assert + transaction.Version.Should().Be(TransactionVersion.V1); + transaction.Message.AccountKeys[0].Should().Be(payer.PublicKey); + bytes[..messageBytes.Length].Should().Equal(messageBytes); + payer.PublicKey.Verify(messageBytes, bytes.AsSpan(messageBytes.Length)).Should().BeTrue(); + } + } + + [TestFixture] + public sealed class SetV1Config + { + [Test] + public void NullConfig_ThrowsAtSetter() + { + // Arrange + var builder = new TransactionBuilder(); + + // Act + Action act = () => builder.SetV1Config(null!); + + // Assert + act.Should().Throw().WithParameterName("config"); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/TransferHookProgramTests.cs b/tests/SolSharp.Programs.Tests/TransferHookProgramTests.cs new file mode 100644 index 0000000..45115ed --- /dev/null +++ b/tests/SolSharp.Programs.Tests/TransferHookProgramTests.cs @@ -0,0 +1,354 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; +using static SolSharp.Programs.Tests.TransferHookTestHelpers; + +namespace SolSharp.Programs.Tests; + +internal static class TransferHookTestHelpers +{ + internal static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + internal static string Hex(byte[] data) => Convert.ToHexString(data).ToLowerInvariant(); + + internal static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; +} + +public static class TransferHookProgramTests +{ + [TestFixture] + public sealed class Execute + { + [Test] + public void MatchesPinnedTransferHookInterface() + { + // Act + var instruction = TransferHookProgram.Execute(Key(9), Key(1), Key(2), Key(3), Key(4), 100); + + // Assert + Hex(instruction.Data).Should().Be("692565c54bfb661a6400000000000000"); + Metas(instruction).Should().Equal( + (Key(1), false, false), + (Key(2), false, false), + (Key(3), false, false), + (Key(4), false, false)); + } + } + + [TestFixture] + public sealed class InitializeExtraAccountMetaList + { + [Test] + public void MatchesPinnedTransferHookInterface() + { + // Arrange + var fixedMeta = ExtraAccountMeta.FromPublicKey(Key(6), isSigner: false, isWritable: true); + + // Act + var instruction = TransferHookProgram.InitializeExtraAccountMetaList( + Key(9), Key(5), Key(2), Key(4), [fixedMeta]); + + // Assert + Hex(instruction.Data).Should().Be( + "2b220d31a758ebeb01000000" + + "00" + "0606060606060606060606060606060606060606060606060606060606060606" + "0001"); + Metas(instruction).Should().Equal( + (Key(5), false, true), + (Key(2), false, false), + (Key(4), true, false), + (PublicKey.Parse(SolanaProgramIds.SystemProgram), false, false)); + } + } + + [TestFixture] + public sealed class UpdateExtraAccountMetaList + { + [Test] + public void MatchesPinnedTransferHookInterface() + { + // Arrange + var fixedMeta = ExtraAccountMeta.FromPublicKey(Key(6), isSigner: false, isWritable: true); + + // Act + var instruction = TransferHookProgram.UpdateExtraAccountMetaList( + Key(9), Key(5), Key(2), Key(4), [fixedMeta]); + + // Assert + Hex(instruction.Data).Should().Be( + "9d692a926655f1ae01000000" + + "00" + "0606060606060606060606060606060606060606060606060606060606060606" + "0001"); + } + } + + [TestFixture] + public sealed class EncodeExecuteExtraAccountMetaList + { + [Test] + public void MatchesPinnedTlvLayout() + { + // Arrange + var meta = ExtraAccountMeta.FromPublicKey(Key(1), isSigner: true, isWritable: false); + + // Act + var encoded = TransferHookProgram.EncodeExecuteExtraAccountMetaList([meta]); + + // Assert + Hex(encoded).Should().Be( + "692565c54bfb661a2700000001000000" + + "00" + "0101010101010101010101010101010101010101010101010101010101010101" + "0100"); + } + } + + [TestFixture] + public sealed class DecodeExecuteExtraAccountMetaList + { + [Test] + public void PinnedTlvLayout_RoundTrips() + { + // Arrange + var meta = ExtraAccountMeta.FromPublicKey(Key(1), isSigner: true, isWritable: false); + var encoded = TransferHookProgram.EncodeExecuteExtraAccountMetaList([meta]); + + // Act + var decoded = TransferHookProgram.DecodeExecuteExtraAccountMetaList(encoded); + + // Assert + decoded.Should().ContainSingle(); + Hex(decoded![0].Encode()).Should().Be(Hex(meta.Encode())); + } + + [Test] + public void TruncatedTlvLayout_IsRejected() + { + // Arrange + var meta = ExtraAccountMeta.FromPublicKey(Key(1), isSigner: true, isWritable: false); + var encoded = TransferHookProgram.EncodeExecuteExtraAccountMetaList([meta]); + + // Act & Assert + TransferHookProgram.DecodeExecuteExtraAccountMetaList(encoded.AsSpan()[..^1]).Should().BeNull(); + } + } + + [TestFixture] + public sealed class GetExtraAccountMetaListSize + { + [Test] + public void SingleEntry_HasPinnedTlvSize() + => TransferHookProgram.GetExtraAccountMetaListSize(1).Should().Be(51); + } + + [TestFixture] + public sealed class ResolveExecuteExtraAccountMetasAsync + { + [Test] + public async Task ResolvesPinnedOffchainAccountOrderingAndPdas() + { + // Arrange + var hookProgram = Key(9); + var source = Key(1); + var mint = Key(2); + var destination = Key(3); + var authority = Key(4); + var staticExtra = Key(6); + const ulong amount = 100; + var validation = TransferHookProgram.GetExtraAccountMetasAddress(mint, hookProgram); + var firstPda = ExtraAccountMeta.FromProgramDerivedAddress( + [ + ExtraAccountSeed.FromAccountKey(0), + ExtraAccountSeed.FromAccountKey(2), + ExtraAccountSeed.FromAccountKey(4) + ], + isSigner: false, + isWritable: true); + var secondPda = ExtraAccountMeta.FromProgramDerivedAddress( + [ + ExtraAccountSeed.FromInstructionData(8, 8), + ExtraAccountSeed.FromAccountKey(2), + ExtraAccountSeed.FromAccountKey(5), + ExtraAccountSeed.FromAccountKey(6) + ], + isSigner: false, + isWritable: true); + var validationData = TransferHookProgram.EncodeExecuteExtraAccountMetaList( + [ + ExtraAccountMeta.FromPublicKey(staticExtra, isSigner: true, isWritable: false), + firstPda, + secondPda + ]); + var expectedFirstPda = ProgramDerivedAddress.FindProgramAddress( + [source.ToBytes(), destination.ToBytes(), validation.ToBytes()], hookProgram).Address; + var expectedSecondPda = ProgramDerivedAddress.FindProgramAddress( + [ + [100, 0, 0, 0, 0, 0, 0, 0], + destination.ToBytes(), + staticExtra.ToBytes(), + expectedFirstPda.ToBytes() + ], + hookProgram).Address; + + ValueTask?> Resolve(PublicKey key, CancellationToken cancellationToken) + => ValueTask.FromResult?>(key == validation ? validationData : null); + + // Act + var extras = await TransferHookProgram.ResolveExecuteExtraAccountMetasAsync( + hookProgram, + source, + mint, + destination, + authority, + amount, + validationData, + Resolve); + + // Assert + extras.Select(meta => (meta.PublicKey, meta.IsSigner, meta.IsWritable)).Should().Equal( + (staticExtra, false, false), + (expectedFirstPda, false, true), + (expectedSecondPda, false, true)); + } + } + + [TestFixture] + public sealed class AddExtraAccountsForExecuteAsync + { + [Test] + public async Task AppendsExtrasThenHookProgramAndValidationAccount() + { + // Arrange + var hookProgram = Key(9); + var source = Key(1); + var mint = Key(2); + var destination = Key(3); + var authority = Key(4); + var validation = TransferHookProgram.GetExtraAccountMetasAddress(mint, hookProgram); + var validationData = TransferHookProgram.EncodeExecuteExtraAccountMetaList( + [ExtraAccountMeta.FromPublicKey(Key(6), false, true)]); + var transfer = TokenProgram.TransferChecked( + source, + mint, + destination, + authority, + 5, + 2, + PublicKey.Parse(SolanaProgramIds.Token2022Program)); + + ValueTask?> Resolve(PublicKey key, CancellationToken cancellationToken) + => ValueTask.FromResult?>(key == validation ? validationData : null); + + // Act + var augmented = await TransferHookProgram.AddExtraAccountsForExecuteAsync( + transfer, + hookProgram, + source, + mint, + destination, + authority, + 5, + Resolve); + + // Assert + augmented.Accounts.TakeLast(3).Select(meta => meta.PublicKey).Should().Equal(Key(6), hookProgram, validation); + } + } +} + +public static class ExtraAccountMetaTests +{ + [TestFixture] + public sealed class FromPublicKey + { + [Test] + public void MatchesPinnedTlvResolutionLayout() + { + // Act + var meta = ExtraAccountMeta.FromPublicKey(Key(6), isSigner: false, isWritable: true); + + // Assert + Hex(meta.Encode()).Should().Be( + "00" + "0606060606060606060606060606060606060606060606060606060606060606" + "0001"); + } + } + + [TestFixture] + public sealed class FromProgramDerivedAddress + { + [Test] + public void MatchesPinnedTlvResolutionLayout() + { + // Arrange + var seeds = new[] + { + ExtraAccountSeed.Literal("ab"u8), + ExtraAccountSeed.FromInstructionData(8, 8), + ExtraAccountSeed.FromAccountKey(2), + ExtraAccountSeed.FromAccountData(1, 3, 4) + }; + + // Act + var meta = ExtraAccountMeta.FromProgramDerivedAddress(seeds, isSigner: false, isWritable: true); + + // Assert + Hex(meta.Encode()).Should().Be( + "01" + "01026162020808030204010304" + new string('0', 19 * 2) + "0001"); + } + } + + [TestFixture] + public sealed class FromInstructionDataPublicKey + { + [Test] + public void MatchesPinnedTlvResolutionLayout() + { + // Act + var meta = ExtraAccountMeta.FromInstructionDataPublicKey(7, false, false); + + // Assert + Hex(meta.Encode()).Should().Be("02" + "0107" + new string('0', 30 * 2) + "0000"); + } + } + + [TestFixture] + public sealed class FromAccountDataPublicKey + { + [Test] + public void MatchesPinnedTlvResolutionLayout() + { + // Act + var meta = ExtraAccountMeta.FromAccountDataPublicKey(4, 9, false, true); + + // Assert + Hex(meta.Encode()).Should().Be("02" + "020409" + new string('0', 29 * 2) + "0001"); + } + } + + [TestFixture] + public sealed class DecodeSeeds + { + [Test] + public void MatchesPinnedTlvResolutionLayout() + { + // Arrange + var meta = ExtraAccountMeta.FromProgramDerivedAddress( + [ + ExtraAccountSeed.Literal("ab"u8), + ExtraAccountSeed.FromInstructionData(8, 8), + ExtraAccountSeed.FromAccountKey(2), + ExtraAccountSeed.FromAccountData(1, 3, 4) + ], + isSigner: false, + isWritable: true); + + // Act + var seeds = meta.DecodeSeeds(); + + // Assert + seeds!.Select(seed => seed.Kind).Should().Equal( + ExtraAccountSeedKind.Literal, + ExtraAccountSeedKind.InstructionData, + ExtraAccountSeedKind.AccountKey, + ExtraAccountSeedKind.AccountData); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/VoteInitializeV2DirectCoverageTests.cs b/tests/SolSharp.Programs.Tests/VoteInitializeV2DirectCoverageTests.cs new file mode 100644 index 0000000..0625389 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/VoteInitializeV2DirectCoverageTests.cs @@ -0,0 +1,35 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class VoteInitializeV2DirectCoverageTests +{ + private static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + [TestFixture] + public sealed class Constructor + { + [Test] + public void RawCredentials_ExposeEveryPinnedInitializationField() + { + // Arrange + var publicKey = Enumerable.Repeat((byte)3, VoteAuthorization.BlsPublicKeyLength).ToArray(); + var proof = Enumerable.Repeat((byte)4, VoteAuthorization.BlsProofOfPossessionLength).ToArray(); + + // Act + var initialize = new VoteInitializeV2( + Key(1), Key(2), publicKey, proof, Key(5), 0x1234, 0xabcd); + + // Assert + initialize.Node.Should().Be(Key(1)); + initialize.AuthorizedVoter.Should().Be(Key(2)); + initialize.BlsPublicKey.ToArray().Should().Equal(publicKey); + initialize.BlsProofOfPossession.ToArray().Should().Equal(proof); + initialize.AuthorizedWithdrawer.Should().Be(Key(5)); + initialize.InflationRewardsCommissionBps.Should().Be(0x1234); + initialize.BlockRevenueCommissionBps.Should().Be(0xabcd); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/VoteProgramBlsTests.cs b/tests/SolSharp.Programs.Tests/VoteProgramBlsTests.cs new file mode 100644 index 0000000..81c5086 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/VoteProgramBlsTests.cs @@ -0,0 +1,261 @@ +using System.Runtime.InteropServices; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; +using SolSharp.Wallet; +using static SolSharp.Programs.Tests.VoteProgramBlsTestHelpers; + +namespace SolSharp.Programs.Tests; + +internal static class VoteProgramBlsTestHelpers +{ + internal static PublicKey Key(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); +} + +public static class VoteProgramBlsTests +{ + [TestFixture] + public sealed class Authorize + { + [Test] + public void TypedAuthorization_PreservesExistingWireEncoding() + { + // Arrange + using var keypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); + var proof = keypair.CreateVoteProofOfPossession(Key(1)); + var typed = VoteAuthorization.VoterWithBls(keypair.PublicKey, proof); + var raw = VoteAuthorization.VoterWithBls(keypair.PublicKey.ToBytes(), proof.ToBytes()); + + // Act + var typedInstruction = VoteProgram.Authorize(Key(1), Key(2), Key(3), typed); + var rawInstruction = VoteProgram.Authorize(Key(1), Key(2), Key(3), raw); + + // Assert + typedInstruction.Data.Should().Equal(rawInstruction.Data); + } + + [Test] + public void DefensiveCredentialCopies_CannotMutateTypedAuthorizationWire() + { + // Arrange + using var keypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); + var proof = keypair.CreateVoteProofOfPossession(Key(1)); + var typed = VoteAuthorization.VoterWithBls(keypair.PublicKey, proof); + var expectedPublicKey = keypair.PublicKey.ToBytes(); + var expectedProof = proof.ToBytes(); + var before = VoteProgram.Authorize(Key(1), Key(2), Key(3), typed); + + // Act + var publicKeyIsArrayBacked = MemoryMarshal.TryGetArray( + typed.BlsPublicKey, out var publicKeySegment); + var proofIsArrayBacked = MemoryMarshal.TryGetArray( + typed.BlsProofOfPossession, out var proofSegment); + if (publicKeyIsArrayBacked) + publicKeySegment.Array![publicKeySegment.Offset] ^= byte.MaxValue; + if (proofIsArrayBacked) + proofSegment.Array![proofSegment.Offset] ^= byte.MaxValue; + var after = VoteProgram.Authorize(Key(1), Key(2), Key(3), typed); + + // Assert + publicKeyIsArrayBacked.Should().BeTrue(); + proofIsArrayBacked.Should().BeTrue(); + typed.BlsPublicKey.ToArray().Should().Equal(expectedPublicKey); + typed.BlsProofOfPossession.ToArray().Should().Equal(expectedProof); + after.Data.Should().Equal(before.Data); + } + + [Test] + public void TypedAuthorization_MismatchedVoteAccountOrBlsKeyIsRejected() + { + // Arrange + using var keypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); + using var otherKeypair = BlsKeypair.Derive(Enumerable.Range(1, 32).Select(value => (byte)value).ToArray()); + var proof = keypair.CreateVoteProofOfPossession(Key(9)); + var typed = VoteAuthorization.VoterWithBls(keypair.PublicKey, proof); + var wrongKey = VoteAuthorization.VoterWithBls(otherKeypair.PublicKey, proof); + var raw = VoteAuthorization.VoterWithBls(keypair.PublicKey.ToBytes(), proof.ToBytes()); + + // Act + Action voteAccountMismatch = () => _ = VoteProgram.Authorize(Key(1), Key(2), Key(3), typed); + Action keyMismatch = () => _ = VoteProgram.Authorize(Key(9), Key(2), Key(3), wrongKey); + Action rawAuthorize = () => _ = VoteProgram.Authorize(Key(1), Key(2), Key(3), raw); + + // Assert + voteAccountMismatch.Should().Throw().WithMessage("*vote account*"); + keyMismatch.Should().Throw().WithMessage("*BLS public key*"); + rawAuthorize.Should().NotThrow(); + } + } + + [TestFixture] + public sealed class AuthorizeChecked + { + [Test] + public void TypedAuthorization_MismatchedVoteAccountIsRejected() + { + // Arrange + using var keypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); + var proof = keypair.CreateVoteProofOfPossession(Key(9)); + var typed = VoteAuthorization.VoterWithBls(keypair.PublicKey, proof); + + // Act + Action act = () => _ = VoteProgram.AuthorizeChecked(Key(1), Key(2), Key(3), typed); + + // Assert + act.Should().Throw().WithMessage("*vote account*"); + } + } + + [TestFixture] + public sealed class AuthorizeWithSeed + { + [Test] + public void TypedAuthorization_MismatchedVoteAccountIsRejected() + { + // Arrange + using var keypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); + var proof = keypair.CreateVoteProofOfPossession(Key(9)); + var typed = VoteAuthorization.VoterWithBls(keypair.PublicKey, proof); + + // Act + Action act = () => _ = VoteProgram.AuthorizeWithSeed(Key(1), Key(2), Key(4), "seed", Key(3), typed); + + // Assert + act.Should().Throw().WithMessage("*vote account*"); + } + } + + [TestFixture] + public sealed class AuthorizeCheckedWithSeed + { + [Test] + public void TypedAuthorization_MismatchedVoteAccountIsRejected() + { + // Arrange + using var keypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); + var proof = keypair.CreateVoteProofOfPossession(Key(9)); + var typed = VoteAuthorization.VoterWithBls(keypair.PublicKey, proof); + + // Act + Action act = () => + _ = VoteProgram.AuthorizeCheckedWithSeed(Key(1), Key(2), Key(4), "seed", Key(3), typed); + + // Assert + act.Should().Throw().WithMessage("*vote account*"); + } + } + + [TestFixture] + public sealed class InitializeAccountV2 + { + [Test] + public void TypedInitialize_PreservesExistingWireEncoding() + { + // Arrange + using var keypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); + var proof = keypair.CreateVoteProofOfPossession(Key(9)); + var typed = new VoteInitializeV2(Key(1), Key(2), keypair.PublicKey, proof, Key(3), 25, 75); + var raw = new VoteInitializeV2( + Key(1), + Key(2), + keypair.PublicKey.ToBytes(), + proof.ToBytes(), + Key(3), + 25, + 75); + + // Act + var typedInstruction = VoteProgram.InitializeAccountV2(Key(9), typed, Key(4), Key(5)); + var rawInstruction = VoteProgram.InitializeAccountV2(Key(9), raw, Key(4), Key(5)); + + // Assert + typedInstruction.Data.Should().Equal(rawInstruction.Data); + typed.BlsPublicKey.ToArray().Should().Equal(keypair.PublicKey.ToBytes()); + typed.BlsProofOfPossession.ToArray().Should().Equal(proof.ToBytes()); + } + + [Test] + public void DefensiveCredentialCopies_CannotMutateTypedInitializeWire() + { + // Arrange + using var keypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); + var proof = keypair.CreateVoteProofOfPossession(Key(9)); + var typed = new VoteInitializeV2(Key(1), Key(2), keypair.PublicKey, proof, Key(3), 25, 75); + var expectedPublicKey = keypair.PublicKey.ToBytes(); + var expectedProof = proof.ToBytes(); + var before = VoteProgram.InitializeAccountV2(Key(9), typed, Key(4), Key(5)); + + // Act + var publicKeyIsArrayBacked = MemoryMarshal.TryGetArray( + typed.BlsPublicKey, out var publicKeySegment); + var proofIsArrayBacked = MemoryMarshal.TryGetArray( + typed.BlsProofOfPossession, out var proofSegment); + if (publicKeyIsArrayBacked) + publicKeySegment.Array![publicKeySegment.Offset] ^= byte.MaxValue; + if (proofIsArrayBacked) + proofSegment.Array![proofSegment.Offset] ^= byte.MaxValue; + var after = VoteProgram.InitializeAccountV2(Key(9), typed, Key(4), Key(5)); + + // Assert + publicKeyIsArrayBacked.Should().BeTrue(); + proofIsArrayBacked.Should().BeTrue(); + typed.BlsPublicKey.ToArray().Should().Equal(expectedPublicKey); + typed.BlsProofOfPossession.ToArray().Should().Equal(expectedProof); + after.Data.Should().Equal(before.Data); + } + + [Test] + public void TypedInitialize_MismatchedVoteAccountIsRejected() + { + // Arrange + using var keypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); + var proof = keypair.CreateVoteProofOfPossession(Key(9)); + var typed = new VoteInitializeV2(Key(1), Key(2), keypair.PublicKey, proof, Key(3), 25, 75); + + // Act + Action act = () => _ = VoteProgram.InitializeAccountV2(Key(8), typed, Key(4), Key(5)); + + // Assert + act.Should().Throw().WithMessage("*vote account*"); + } + } + + [TestFixture] + public sealed class CreateAccountV2 + { + [Test] + public void TypedInitialize_MismatchedVoteAccountIsRejected() + { + // Arrange + using var keypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); + var proof = keypair.CreateVoteProofOfPossession(Key(9)); + var typed = new VoteInitializeV2(Key(1), Key(2), keypair.PublicKey, proof, Key(3), 25, 75); + + // Act + Action act = () => _ = VoteProgram.CreateAccountV2(Key(6), Key(8), typed, Key(4), Key(5), 1); + + // Assert + act.Should().Throw().WithMessage("*vote account*"); + } + } + + [TestFixture] + public sealed class CreateAccountV2WithSeed + { + [Test] + public void TypedInitialize_MismatchedVoteAccountIsRejected() + { + // Arrange + using var keypair = BlsKeypair.Derive(Enumerable.Range(0, 32).Select(value => (byte)value).ToArray()); + var proof = keypair.CreateVoteProofOfPossession(Key(9)); + var typed = new VoteInitializeV2(Key(1), Key(2), keypair.PublicKey, proof, Key(3), 25, 75); + + // Act + Action act = () => + _ = VoteProgram.CreateAccountV2WithSeed(Key(6), Key(8), Key(7), "seed", typed, Key(4), Key(5), 1); + + // Assert + act.Should().Throw().WithMessage("*vote account*"); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/VoteProgramParityTests.cs b/tests/SolSharp.Programs.Tests/VoteProgramParityTests.cs new file mode 100644 index 0000000..68045a9 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/VoteProgramParityTests.cs @@ -0,0 +1,405 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; +using static SolSharp.Programs.Tests.VoteProgramParityTestHelpers; + +namespace SolSharp.Programs.Tests; + +internal static class VoteProgramParityTestHelpers +{ + internal static PublicKey Pk(byte value) => new(Enumerable.Repeat(value, PublicKey.Length).ToArray()); + + internal static Hash H(byte value) => new(Enumerable.Repeat(value, Hash.Length).ToArray()); + + internal static string Repeat(byte value, int count) + => string.Concat(Enumerable.Repeat(value.ToString("x2"), count)); + + internal static string Hex(Instruction instruction) => Convert.ToHexString(instruction.Data).ToLowerInvariant(); + + internal static (PublicKey, bool, bool)[] Metas(Instruction instruction) + => [.. instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable))]; + + internal static VoteStateUpdate Update() + => new([new VoteLockout(7, 1), new VoteLockout(300, 2)], 5, H(9), -2); + + internal static string CompactBody() + => "0500000000000000" + + "02" + + "0201" + + "a50202" + + Repeat(9, 32) + + "01feffffffffffffff"; +} + +public static class VoteProgramParityTests +{ + [TestFixture] + public sealed class InitializeAccount + { + [Test] + public void MatchesPinnedLegacyFieldOrderAndAccounts() + { + // Arrange + var initialize = new VoteInitialize(Pk(1), Pk(2), Pk(3), 7); + + // Act + var instruction = VoteProgram.InitializeAccount(Pk(9), initialize); + + // Assert + Hex(instruction).Should().Be( + "00000000" + Repeat(1, 32) + Repeat(2, 32) + Repeat(3, 32) + "07"); + Metas(instruction).Should().Equal( + (Pk(9), false, true), + (PublicKey.Parse(Sysvars.Rent), false, false), + (PublicKey.Parse(Sysvars.Clock), false, false), + (Pk(1), true, false)); + } + } + + [TestFixture] + public sealed class CreateAccount + { + [Test] + public void ComposesPinnedSystemCreateAndLegacyInitialize() + { + // Arrange + const ulong lamports = 123; + var initialize = new VoteInitialize(Pk(1), Pk(2), Pk(3), 7); + var expectedCreate = SystemProgram.CreateAccount( + Pk(4), Pk(5), lamports, VoteProgram.AccountDataLength, VoteProgram.ProgramId); + var expectedInitialize = VoteProgram.InitializeAccount(Pk(5), initialize); + + // Act + var instructions = VoteProgram.CreateAccount(Pk(4), Pk(5), initialize, lamports); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Should().BeEquivalentTo(expectedCreate); + instructions[1].Should().BeEquivalentTo(expectedInitialize); + } + } + + [TestFixture] + public sealed class CreateAccountWithSeed + { + [Test] + public void ComposesPinnedSystemCreateWithSeedAndLegacyInitialize() + { + // Arrange + const ulong lamports = 123; + const string seed = "vote-seed"; + var initialize = new VoteInitialize(Pk(1), Pk(2), Pk(3), 7); + var expectedCreate = SystemProgram.CreateAccountWithSeed( + Pk(4), + Pk(5), + Pk(6), + seed, + lamports, + VoteProgram.AccountDataLength, + VoteProgram.ProgramId); + var expectedInitialize = VoteProgram.InitializeAccount(Pk(5), initialize); + + // Act + var instructions = VoteProgram.CreateAccountWithSeed( + Pk(4), Pk(5), Pk(6), seed, initialize, lamports); + + // Assert + instructions.Should().HaveCount(2); + instructions[0].Should().BeEquivalentTo(expectedCreate); + instructions[1].Should().BeEquivalentTo(expectedInitialize); + } + } + + [TestFixture] + public sealed class InitializeAccountV2 + { + [Test] + public void MatchesPinnedFieldOrderAndWidths() + { + // Arrange + var initialize = new VoteInitializeV2( + Pk(1), + Pk(2), + Enumerable.Repeat((byte)3, VoteAuthorization.BlsPublicKeyLength).ToArray(), + Enumerable.Repeat((byte)4, VoteAuthorization.BlsProofOfPossessionLength).ToArray(), + Pk(5), + 0x1234, + 0xabcd); + + // Act + var instruction = VoteProgram.InitializeAccountV2(Pk(9), initialize, Pk(6), Pk(7)); + + // Assert + Hex(instruction).Should().Be( + "10000000" + Repeat(1, 32) + Repeat(2, 32) + Repeat(3, 48) + Repeat(4, 96) + + Repeat(5, 32) + "3412cdab"); + instruction.Accounts.Select(account => (account.PublicKey, account.IsSigner, account.IsWritable)) + .Should().Equal( + (Pk(9), false, true), + (Pk(1), true, false), + (Pk(6), false, true), + (Pk(7), false, true)); + } + } + + [TestFixture] + public sealed class Authorize + { + [Test] + public void MatchesBincodeEnumAndStructOrder() + { + // Arrange + var authorization = VoteAuthorization.VoterWithBls( + Enumerable.Repeat((byte)4, VoteAuthorization.BlsPublicKeyLength).ToArray(), + Enumerable.Repeat((byte)5, VoteAuthorization.BlsProofOfPossessionLength).ToArray()); + + // Act + var instruction = VoteProgram.Authorize(Pk(1), Pk(2), Pk(3), authorization); + + // Assert + Hex(instruction).Should().Be( + "01000000" + Repeat(3, 32) + "02000000" + Repeat(4, 48) + Repeat(5, 96)); + } + } + + [TestFixture] + public sealed class AuthorizeWithSeed + { + [Test] + public void MatchesBincodeEnumAndStructOrder() + => Hex(VoteProgram.AuthorizeWithSeed(Pk(1), Pk(2), Pk(4), "ab", Pk(3), VoteAuthorization.Withdrawer)) + .Should().Be( + "0a00000001000000" + Repeat(4, 32) + + "02000000000000006162" + Repeat(3, 32)); + } + + [TestFixture] + public sealed class UpdateCommissionCollector + { + [Test] + public void MatchesBincodeEnumAndStructOrder() + => Hex(VoteProgram.UpdateCommissionCollector(Pk(1), Pk(2), Pk(3), VoteCommissionKind.BlockRevenue)) + .Should().Be("1100000001000000"); + } + + [TestFixture] + public sealed class UpdateCommissionBps + { + [Test] + public void MatchesBincodeEnumAndStructOrder() + => Hex(VoteProgram.UpdateCommissionBps(Pk(1), Pk(2), VoteCommissionKind.BlockRevenue, 0x1234)) + .Should().Be("12000000341201000000"); + } + + [TestFixture] + public sealed class DepositDelegatorRewards + { + [Test] + public void MatchesBincodeEnumAndStructOrder() + => Hex(VoteProgram.DepositDelegatorRewards(Pk(1), Pk(2), 0x0102030405060708)) + .Should().Be("130000000807060504030201"); + } + + [TestFixture] + public sealed class UpdateVoteState + { + [Test] + public void MatchesBincodeVecDequeAndOptions() + { + // Arrange + var update = Update(); + + // Act + var instruction = VoteProgram.UpdateVoteState(Pk(1), Pk(2), update); + + // Assert + Hex(instruction).Should().Be( + "08000000" + + "0200000000000000" + + "070000000000000001000000" + + "2c0100000000000002000000" + + "010500000000000000" + Repeat(9, 32) + + "01feffffffffffffff"); + } + } + + [TestFixture] + public sealed class CompactUpdateVoteState + { + [Test] + public void MatchesShortVecAndUnsignedLeb128() + { + // Act + var instruction = VoteProgram.CompactUpdateVoteState(Pk(1), Pk(2), Update()); + + // Assert + Hex(instruction).Should().Be("0c000000" + CompactBody()); + } + + [Test] + public void NonMonotonicSlotsAndWideConfirmationCounts_AreRejected() + { + // Arrange + var descending = new VoteStateUpdate([new VoteLockout(9, 1), new VoteLockout(8, 1)], null, H(1)); + var wide = new VoteStateUpdate([new VoteLockout(9, 256)], null, H(1)); + + // Act + Action descendingAction = () => VoteProgram.CompactUpdateVoteState(Pk(1), Pk(2), descending); + Action wideAction = () => VoteProgram.CompactUpdateVoteState(Pk(1), Pk(2), wide); + + // Assert + descendingAction.Should().Throw(); + wideAction.Should().Throw(); + } + } + + [TestFixture] + public sealed class CompactUpdateVoteStateSwitch + { + [Test] + public void MatchesShortVecAndUnsignedLeb128() + { + // Act + var instruction = VoteProgram.CompactUpdateVoteStateSwitch(Pk(1), Pk(2), Update(), H(8)); + + // Assert + Hex(instruction).Should().Be("0d000000" + CompactBody() + Repeat(8, 32)); + } + } + + [TestFixture] + public sealed class TowerSync + { + [Test] + public void MatchesShortVecAndUnsignedLeb128() + { + // Arrange + var update = Update(); + var tower = new VoteTowerSync(update.Lockouts, update.Root, update.Hash, H(10), update.Timestamp); + + // Act + var instruction = VoteProgram.TowerSync(Pk(1), Pk(2), tower); + + // Assert + Hex(instruction).Should().Be("0e000000" + CompactBody() + Repeat(10, 32)); + } + } + + [TestFixture] + public sealed class TowerSyncSwitch + { + [Test] + public void MatchesShortVecAndUnsignedLeb128() + { + // Arrange + var update = Update(); + var tower = new VoteTowerSync(update.Lockouts, update.Root, update.Hash, H(10), update.Timestamp); + + // Act + var instruction = VoteProgram.TowerSyncSwitch(Pk(1), Pk(2), tower, H(8)); + + // Assert + Hex(instruction).Should().Be("0f000000" + CompactBody() + Repeat(10, 32) + Repeat(8, 32)); + } + } + + [TestFixture] + public sealed class Vote + { + [Test] + public void UsesPinnedDiscriminator() + => Hex(VoteProgram.Vote(Pk(1), Pk(2), new VoteData([], H(3)))).Should().StartWith("02000000"); + } + + [TestFixture] + public sealed class Withdraw + { + [Test] + public void UsesPinnedDiscriminator() + => Hex(VoteProgram.Withdraw(Pk(1), Pk(2), 9, Pk(3))).Should().Be("030000000900000000000000"); + } + + [TestFixture] + public sealed class UpdateValidatorIdentity + { + [Test] + public void UsesPinnedDiscriminator() + => Hex(VoteProgram.UpdateValidatorIdentity(Pk(1), Pk(2), Pk(3))).Should().Be("04000000"); + } + + [TestFixture] + public sealed class UpdateCommission + { + [Test] + public void UsesPinnedDiscriminator() + => Hex(VoteProgram.UpdateCommission(Pk(1), Pk(2), 7)).Should().Be("0500000007"); + } + + [TestFixture] + public sealed class VoteSwitch + { + [Test] + public void UsesPinnedDiscriminator() + => Hex(VoteProgram.VoteSwitch(Pk(1), Pk(2), new VoteData([], H(3)), H(4))) + .Should().StartWith("06000000"); + } + + [TestFixture] + public sealed class AuthorizeChecked + { + [Test] + public void UsesPinnedDiscriminator() + => Hex(VoteProgram.AuthorizeChecked(Pk(1), Pk(2), Pk(3), VoteAuthorization.Voter)) + .Should().Be("0700000000000000"); + } + + [TestFixture] + public sealed class UpdateVoteStateSwitch + { + [Test] + public void UsesPinnedDiscriminator() + => Hex(VoteProgram.UpdateVoteStateSwitch( + Pk(1), + Pk(2), + new VoteStateUpdate([], null, H(3)), + H(4))) + .Should().StartWith("09000000"); + } + + [TestFixture] + public sealed class AuthorizeCheckedWithSeed + { + [Test] + public void UsesPinnedDiscriminator() + => Hex(VoteProgram.AuthorizeCheckedWithSeed( + Pk(1), + Pk(2), + Pk(4), + "ab", + Pk(3), + VoteAuthorization.Voter)) + .Should().StartWith("0b000000"); + } +} + +public static class VoteInitializeTests +{ + [TestFixture] + public sealed class Constructor + { + [Test] + public void PreservesEveryLegacyInitializationField() + { + // Act + var initialize = new VoteInitialize(Pk(1), Pk(2), Pk(3), 7); + + // Assert + initialize.Node.Should().Be(Pk(1)); + initialize.AuthorizedVoter.Should().Be(Pk(2)); + initialize.AuthorizedWithdrawer.Should().Be(Pk(3)); + initialize.Commission.Should().Be(7); + initialize.Should().Be(new VoteInitialize(Pk(1), Pk(2), Pk(3), 7)); + } + } +} diff --git a/tests/SolSharp.Programs.Tests/VoteStateVersionsTests.cs b/tests/SolSharp.Programs.Tests/VoteStateVersionsTests.cs new file mode 100644 index 0000000..0e857a6 --- /dev/null +++ b/tests/SolSharp.Programs.Tests/VoteStateVersionsTests.cs @@ -0,0 +1,262 @@ +using System.Buffers.Binary; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Programs.Tests; + +public static class VoteStateVersionsTests +{ + [TestFixture] + public sealed class Parse + { + [Test] + public void PinnedV1BincodeVector_PreservesLegacyVariantAndFieldOrder() + { + // Arrange + var data = BuildLegacyVector(VoteStateVersion.V1_14_11); + + // Act + var state = VoteStateVersions.Parse(data).Should().BeOfType().Subject; + + // Assert + state.Version.Should().Be(VoteStateVersion.V1_14_11); + state.Node.ToBytes().Should().OnlyContain(value => value == 1); + state.AuthorizedWithdrawer.ToBytes().Should().OnlyContain(value => value == 2); + state.Commission.Should().Be(3); + state.Votes.Should().Equal(new VoteStateLockout(0, 4, 5)); + state.RootSlot.Should().Be(6); + state.AuthorizedVoters.Should().Equal( + new AuthorizedVoteVoter(7, new PublicKey(Enumerable.Repeat((byte)8, 32).ToArray()))); + state.PriorVoters.Should().HaveCount(VoteStateVersions.PriorVoterEntries); + state.PriorVoters[0].Should().Be( + new PriorVoteVoter(new PublicKey(Enumerable.Repeat((byte)9, 32).ToArray()), 10, 11)); + state.PriorVoterIndex.Should().Be(31); + state.PriorVotersEmpty.Should().BeFalse(); + state.EpochCredits.Should().Equal(new VoteEpochCredits(12, 13, 14)); + state.LastTimestamp.Should().Be(new VoteStateTimestamp(15, -16)); + state.IsUninitialized.Should().BeFalse(); + VoteStateV1_14_11.DataLength.Should().Be(3_731); + } + + [Test] + public void PinnedV3BincodeVector_ReadsLatencyBeforeLockout() + { + // Arrange + var data = BuildLegacyVector(VoteStateVersion.V3); + + // Act + var state = VoteStateVersions.Parse(data).Should().BeOfType().Subject; + + // Assert + state.Version.Should().Be(VoteStateVersion.V3); + state.Votes.Should().Equal(new VoteStateLockout(17, 4, 5)); + state.RootSlot.Should().Be(6); + state.Commission.Should().Be(3); + state.PriorVoters.Should().HaveCount(32); + state.PriorVoterIndex.Should().Be(31); + state.PriorVotersEmpty.Should().BeFalse(); + state.IsUninitialized.Should().BeFalse(); + VoteStateV3.DataLength.Should().Be(3_762); + } + + [Test] + public void PinnedV4BincodeVector_DecodesCollectorsCommissionsBlsAndSharedTail() + { + // Arrange + var data = BuildV4Vector(); + + // Act + var state = VoteStateVersions.Parse(data).Should().BeOfType().Subject; + + // Assert + state.Version.Should().Be(VoteStateVersion.V4); + state.Node.ToBytes().Should().OnlyContain(value => value == 21); + state.AuthorizedWithdrawer.ToBytes().Should().OnlyContain(value => value == 22); + state.InflationRewardsCollector.ToBytes().Should().OnlyContain(value => value == 23); + state.BlockRevenueCollector.ToBytes().Should().OnlyContain(value => value == 24); + state.InflationRewardsCommissionBasisPoints.Should().Be(2_526); + state.BlockRevenueCommissionBasisPoints.Should().Be(2_728); + state.PendingDelegatorRewards.Should().Be(29); + state.BlsPublicKey.Should().NotBeNull(); + state.BlsPublicKey!.Value.ToArray().Should().OnlyContain(value => value == 30); + state.Votes.Should().Equal(new VoteStateLockout(31, 32, 33)); + state.RootSlot.Should().Be(34); + state.AuthorizedVoters.Should().Equal( + new AuthorizedVoteVoter(35, new PublicKey(Enumerable.Repeat((byte)36, 32).ToArray()))); + state.EpochCredits.Should().Equal(new VoteEpochCredits(37, 38, 39)); + state.LastTimestamp.Should().Be(new VoteStateTimestamp(40, -41)); + state.IsUninitialized.Should().BeFalse(); + VoteStateV4.DataLength.Should().Be(3_762); + } + + [TestCase(0u)] + [TestCase(4u)] + [TestCase(uint.MaxValue)] + public void UnsupportedRawTag_IsRejected(uint tag) + { + // Arrange + var data = new byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(data, tag); + + // Act & Assert + FluentActions.Invoking(() => VoteStateVersions.Parse(data)) + .Should().Throw().WithMessage($"*{tag}*"); + } + + [Test] + public void HostileVoteCountAboveTowerBound_IsRejectedBeforeAllocation() + { + // Arrange + using var stream = new MemoryStream(); + WriteUInt32(stream, (uint)VoteStateVersion.V3); + WriteRepeated(stream, 0, 64); + stream.WriteByte(0); + WriteUInt64(stream, VoteStateVersions.MaximumLockouts + 1UL); + + // Act & Assert + FluentActions.Invoking(() => VoteStateVersions.Parse(stream.ToArray())) + .Should().Throw().WithMessage("*exceeds the maximum*"); + } + + [Test] + public void NonCanonicalV4BlsOption_IsRejected() + { + // Arrange + var data = BuildV4Vector(); + data[4 + (4 * 32) + (2 * 2) + 8] = 2; + + // Act & Assert + FluentActions.Invoking(() => VoteStateVersions.Parse(data)) + .Should().Throw().WithMessage("*BLS*"); + } + } + + [TestFixture] + public sealed class IsCorrectSizeAndInitialized + { + [Test] + public void ExactPinnedAllocations_ApplyVariantInitializationSentinels() + { + // Arrange + var v1 = new byte[VoteStateV1_14_11.DataLength]; + v1[4] = 1; + var v3 = new byte[VoteStateV3.DataLength]; + v3[4] = 1; + var v4 = new byte[VoteStateV4.DataLength]; + v4[0] = 3; + + // Act + var v1Result = VoteStateV1_14_11.IsCorrectSizeAndInitialized(v1); + var v3Result = VoteStateV3.IsCorrectSizeAndInitialized(v3); + var v4Result = VoteStateV4.IsCorrectSizeAndInitialized(v4); + + // Assert + v1Result.Should().BeTrue(); + v3Result.Should().BeTrue(); + v4Result.Should().BeTrue(); + VoteStateVersions.IsCorrectSizeAndInitialized(v1).Should().BeTrue(); + VoteStateVersions.IsCorrectSizeAndInitialized(v3).Should().BeTrue(); + VoteStateVersions.IsCorrectSizeAndInitialized(v4).Should().BeTrue(); + VoteStateVersions.IsCorrectSizeAndInitialized(v4.AsSpan(0, v4.Length - 1)).Should().BeFalse(); + } + } + + private static byte[] BuildLegacyVector(VoteStateVersion version) + { + using var stream = new MemoryStream(); + WriteUInt32(stream, (uint)version); + WriteRepeated(stream, 1, 32); + WriteRepeated(stream, 2, 32); + stream.WriteByte(3); + WriteUInt64(stream, 1); + if (version is VoteStateVersion.V3) + stream.WriteByte(17); + WriteUInt64(stream, 4); + WriteUInt32(stream, 5); + stream.WriteByte(1); + WriteUInt64(stream, 6); + WriteUInt64(stream, 1); + WriteUInt64(stream, 7); + WriteRepeated(stream, 8, 32); + WriteRepeated(stream, 9, 32); + WriteUInt64(stream, 10); + WriteUInt64(stream, 11); + for (var i = 1; i < VoteStateVersions.PriorVoterEntries; i++) + WriteRepeated(stream, 0, 48); + WriteUInt64(stream, 31); + stream.WriteByte(0); + WriteUInt64(stream, 1); + WriteUInt64(stream, 12); + WriteUInt64(stream, 13); + WriteUInt64(stream, 14); + WriteUInt64(stream, 15); + WriteInt64(stream, -16); + return stream.ToArray(); + } + + private static byte[] BuildV4Vector() + { + using var stream = new MemoryStream(); + WriteUInt32(stream, (uint)VoteStateVersion.V4); + WriteRepeated(stream, 21, 32); + WriteRepeated(stream, 22, 32); + WriteRepeated(stream, 23, 32); + WriteRepeated(stream, 24, 32); + WriteUInt16(stream, 2_526); + WriteUInt16(stream, 2_728); + WriteUInt64(stream, 29); + stream.WriteByte(1); + WriteRepeated(stream, 30, VoteStateVersions.BlsPublicKeyLength); + WriteUInt64(stream, 1); + stream.WriteByte(31); + WriteUInt64(stream, 32); + WriteUInt32(stream, 33); + stream.WriteByte(1); + WriteUInt64(stream, 34); + WriteUInt64(stream, 1); + WriteUInt64(stream, 35); + WriteRepeated(stream, 36, 32); + WriteUInt64(stream, 1); + WriteUInt64(stream, 37); + WriteUInt64(stream, 38); + WriteUInt64(stream, 39); + WriteUInt64(stream, 40); + WriteInt64(stream, -41); + return stream.ToArray(); + } + + private static void WriteRepeated(MemoryStream stream, byte value, int length) + { + for (var i = 0; i < length; i++) + stream.WriteByte(value); + } + + private static void WriteUInt16(MemoryStream stream, ushort value) + { + Span bytes = stackalloc byte[sizeof(ushort)]; + BinaryPrimitives.WriteUInt16LittleEndian(bytes, value); + stream.Write(bytes); + } + + private static void WriteUInt32(MemoryStream stream, uint value) + { + Span bytes = stackalloc byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(bytes, value); + stream.Write(bytes); + } + + private static void WriteUInt64(MemoryStream stream, ulong value) + { + Span bytes = stackalloc byte[sizeof(ulong)]; + BinaryPrimitives.WriteUInt64LittleEndian(bytes, value); + stream.Write(bytes); + } + + private static void WriteInt64(MemoryStream stream, long value) + { + Span bytes = stackalloc byte[sizeof(long)]; + BinaryPrimitives.WriteInt64LittleEndian(bytes, value); + stream.Write(bytes); + } +} diff --git a/tests/SolSharp.Rpc.Tests/AccountFilterTests.cs b/tests/SolSharp.Rpc.Tests/AccountFilterTests.cs new file mode 100644 index 0000000..fc04bf0 --- /dev/null +++ b/tests/SolSharp.Rpc.Tests/AccountFilterTests.cs @@ -0,0 +1,288 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Encoding; +using SolSharp.Core.Primitives; +using SolSharp.Rpc.Streaming; +using SolSharp.Rpc.Tests.Streaming; + +namespace SolSharp.Rpc.Tests; + +public static class AccountFilterTests +{ + private const string ProgramId = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + + [TestFixture] + public sealed class MemoryCompare + { + [Test] + public void ValidBase58_PreservesLegacyFactoryAndCreatesExactPayload() + { + // Arrange + const string encoded = "3Mc6vR"; + + // Act + var filter = AccountFilter.MemoryCompare(7, encoded); + + // Assert + var payload = filter.Payload.Should().BeOfType().Subject.Memcmp; + payload.Offset.Should().Be(7); + payload.Bytes.Should().Be(encoded); + payload.Encoding.Should().Be("base58"); + } + + [Test] + public void NegativeOffset_ThrowsArgumentOutOfRangeException() + { + // Arrange + Action act = () => _ = AccountFilter.MemoryCompare(-1, "1"); + + // Act & Assert + act.Should().Throw().WithParameterName("offset"); + } + } + + [TestFixture] + public sealed class MemoryCompareBase58 + { + [Test] + public void MaximumDecodedLengthAndOffset_AreAccepted() + { + // Arrange + var encoded = Base58.Encode(Enumerable.Repeat(byte.MaxValue, 128).ToArray()); + encoded.Should().HaveLength(175); + + // Act + var filter = AccountFilter.MemoryCompareBase58(ulong.MaxValue, encoded); + + // Assert + var payload = filter.Payload.Should().BeOfType().Subject.Memcmp; + payload.Offset.Should().Be(ulong.MaxValue); + payload.Bytes.Should().Be(encoded); + payload.Encoding.Should().Be("base58"); + } + + [Test] + public void InvalidEncoding_ThrowsArgumentException() + { + // Arrange + Action act = () => _ = AccountFilter.MemoryCompareBase58(0, "III"); + + // Act & Assert + act.Should().Throw().WithParameterName("bytesBase58"); + } + + [Test] + public void MoreThan128DecodedBytes_ThrowsArgumentException() + { + // Arrange: each leading base58 '1' represents one zero byte, matching the pinned Agave boundary KAT. + var encoded = new string('1', 129); + Action act = () => _ = AccountFilter.MemoryCompareBase58(0, encoded); + + // Act & Assert + act.Should().Throw().WithParameterName("bytesBase58"); + } + } + + [TestFixture] + public sealed class MemoryCompareBase64 + { + [Test] + public void MaximumDecodedLengthAndOffset_AreAccepted() + { + // Arrange + var encoded = Convert.ToBase64String(Enumerable.Repeat(byte.MaxValue, 128).ToArray()); + encoded.Should().HaveLength(172); + + // Act + var filter = AccountFilter.MemoryCompareBase64(ulong.MaxValue, encoded); + + // Assert + var payload = filter.Payload.Should().BeOfType().Subject.Memcmp; + payload.Offset.Should().Be(ulong.MaxValue); + payload.Bytes.Should().Be(encoded); + payload.Encoding.Should().Be("base64"); + } + + [Test] + public void InvalidEncoding_ThrowsArgumentException() + { + // Arrange + Action act = () => _ = AccountFilter.MemoryCompareBase64(0, "not-base64"); + + // Act & Assert + act.Should().Throw().WithParameterName("bytesBase64"); + } + + [Test] + public void MoreThan128DecodedBytes_ThrowsArgumentException() + { + // Arrange + var encoded = Convert.ToBase64String(new byte[129]); + Action act = () => _ = AccountFilter.MemoryCompareBase64(0, encoded); + + // Act & Assert + act.Should().Throw().WithParameterName("bytesBase64"); + } + } + + [TestFixture] + public sealed class MemoryCompareRaw + { + [Test] + public void MaximumLengthAndOffset_CreateDefensiveRawPayload() + { + // Arrange + var bytes = Enumerable.Range(0, 128).Select(static value => (byte)value).ToArray(); + + // Act + var filter = AccountFilter.MemoryCompareRaw(ulong.MaxValue, bytes); + bytes[0] = byte.MaxValue; + + // Assert + var payload = filter.Payload.Should().BeOfType().Subject.Memcmp; + payload.Offset.Should().Be(ulong.MaxValue); + payload.Bytes.Should().Equal(Enumerable.Range(0, 128).Select(static value => (byte)value)); + payload.Encoding.Should().Be("bytes"); + } + + [Test] + public void MoreThan128Bytes_ThrowsArgumentException() + { + // Arrange + Action act = () => _ = AccountFilter.MemoryCompareRaw(0, new byte[129]); + + // Act & Assert + act.Should().Throw().WithParameterName("bytes"); + } + } + + [TestFixture] + public sealed class DataSize + { + [Test] + public void PositiveValue_PreservesLegacyFactory() + { + // Arrange & Act + var filter = AccountFilter.DataSize(165); + + // Assert + filter.Payload.Should().BeOfType().Subject.DataSize.Should().Be(165); + } + + [Test] + public void NegativeValue_ThrowsArgumentOutOfRangeException() + { + // Arrange + Action act = () => _ = AccountFilter.DataSize(-1); + + // Act & Assert + act.Should().Throw().WithParameterName("size"); + } + } + + [TestFixture] + public sealed class DataSizeUnsigned + { + [Test] + public void UnsignedMaximum_IsPreserved() + { + // Arrange & Act + var filter = AccountFilter.DataSizeUnsigned(ulong.MaxValue); + + // Assert + filter.Payload.Should().BeOfType().Subject.DataSize.Should().Be(ulong.MaxValue); + } + } + + [TestFixture] + public sealed class TokenAccountState + { + [Test] + public void Factory_CreatesExactUnitVariant() + { + // Arrange & Act + var filter = AccountFilter.TokenAccountState(); + + // Assert + filter.Payload.Should().Be("tokenAccountState"); + } + } + + [TestFixture] + public sealed class GetProgramAccountsAsync + { + [Test] + public async Task FullFilterUnion_SendsExactPinnedAgaveJson() + { + // Arrange + var handler = new FakeHttpMessageHandler("""{"jsonrpc":"2.0","result":[],"id":1}"""); + using var http = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }; + var client = new SolanaRpcClient(http); + var options = new GetProgramAccountsOptions + { + Filters = + [ + AccountFilter.MemoryCompareBase58(ulong.MaxValue, "3Mc6vR"), + AccountFilter.MemoryCompareBase64(8, "AQID"), + AccountFilter.MemoryCompareRaw(9, [0, 1, 2, 255]), + AccountFilter.DataSizeUnsigned(ulong.MaxValue), + AccountFilter.TokenAccountState() + ] + }; + + // Act + var result = await client.GetProgramAccountsAsync(PublicKey.Parse(ProgramId), options); + + // Assert + result.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getProgramAccounts","params":["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",{"encoding":"base64","filters":[{"memcmp":{"offset":18446744073709551615,"bytes":"3Mc6vR","encoding":"base58"}},{"memcmp":{"offset":8,"bytes":"AQID","encoding":"base64"}},{"memcmp":{"offset":9,"bytes":[0,1,2,255],"encoding":"bytes"}},{"dataSize":18446744073709551615},"tokenAccountState"]}]}"""); + } + } + + [TestFixture] + public sealed class SubscribeProgramWithOptionsAsync + { + [Test] + public async Task FullFilterUnion_SendsExactPinnedAgaveJson() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeProgramWithOptionsAsync( + PublicKey.Parse(ProgramId), + new ProgramSubscriptionOptions + { + Encoding = RpcAccountEncoding.Base64, + Filters = + [ + AccountFilter.MemoryCompareBase58(ulong.MaxValue, "3Mc6vR"), + AccountFilter.MemoryCompareBase64(8, "AQID"), + AccountFilter.MemoryCompareRaw(9, [0, 1, 2, 255]), + AccountFilter.DataSizeUnsigned(ulong.MaxValue), + AccountFilter.TokenAccountState() + ] + }); + var request = await NextRequestAsync(fake); + + // Act + fake.PushFromServer("""{"jsonrpc":"2.0","result":41,"id":1}"""); + _ = await subscribe; + + // Assert + request.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"programSubscribe","params":["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",{"encoding":"base64","filters":[{"memcmp":{"offset":18446744073709551615,"bytes":"3Mc6vR","encoding":"base58"}},{"memcmp":{"offset":8,"bytes":"AQID","encoding":"base64"}},{"memcmp":{"offset":9,"bytes":[0,1,2,255],"encoding":"bytes"}},{"dataSize":18446744073709551615},"tokenAccountState"]}]}"""); + } + } + + private static async Task NextRequestAsync(FakeWebSocketConnection connection) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1); + while (connection.SentCount == 0 && DateTime.UtcNow < deadline) + await Task.Yield(); + + connection.SentCount.Should().BeGreaterThan(0); + return connection.SentSnapshot()[0]; + } +} diff --git a/tests/SolSharp.Rpc.Tests/RpcBatchTests.cs b/tests/SolSharp.Rpc.Tests/RpcBatchTests.cs index 77ce52c..e95cc18 100644 --- a/tests/SolSharp.Rpc.Tests/RpcBatchTests.cs +++ b/tests/SolSharp.Rpc.Tests/RpcBatchTests.cs @@ -80,6 +80,49 @@ public async Task PerCallError_FaultsOnlyThatTask() (await act.Should().ThrowAsync()).Which.Code.Should().Be(-32602); } + [Test] + public async Task NullRequiredValues_FaultOnlyThoseCalls() + { + // Arrange + var (client, _) = Make( + """ + [ + {"jsonrpc":"2.0","result":{"context":{"slot":1},"value":null},"id":1}, + {"jsonrpc":"2.0","result":null,"id":2} + ] + """); + var batch = client.CreateBatch(); + var blockhash = batch.GetLatestBlockhashAsync(); + var signature = batch.SendTransactionAsync([1]); + + // Act + await batch.ExecuteAsync(); + var blockhashAct = async () => await blockhash; + var signatureAct = async () => await signature; + + // Assert + await blockhashAct.Should().ThrowAsync(); + await signatureAct.Should().ThrowAsync(); + } + + [Test] + public async Task PerCallError_PreservesStructuredErrorData() + { + // Arrange + var (client, _) = Make( + """[{"jsonrpc":"2.0","error":{"code":-32002,"message":"Simulation failed","data":{"unitsConsumed":99}},"id":1}]"""); + var batch = client.CreateBatch(); + var call = batch.GetSlotAsync(); + + // Act + await batch.ExecuteAsync(); + + // Assert + var act = async () => await call; + var exception = (await act.Should().ThrowAsync()).Which; + exception.ErrorData!.Value.GetProperty("unitsConsumed").GetInt32().Should().Be(99); + } + [Test] public async Task MissingResponseEntry_FaultsThatTask() { @@ -117,6 +160,77 @@ public async Task NonArrayResponse_ThrowsAndFaultsAllTasks() await callAct.Should().ThrowAsync(); } + [TestCase("[null]")] + [TestCase("[1]")] + [TestCase("[[]]")] + [TestCase("[{\"jsonrpc\":\"1.0\",\"result\":1,\"id\":1}]")] + [TestCase("[{\"jsonrpc\":\"2.0\",\"result\":1,\"error\":{\"code\":-1,\"message\":\"bad\"},\"id\":1}]")] + [TestCase("[{\"jsonrpc\":\"2.0\",\"error\":null,\"id\":1}]")] + [TestCase("[{\"jsonrpc\":\"2.0\",\"id\":1}]")] + [TestCase("[{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":2}]")] + [TestCase("[{\"jsonrpc\":\"2.0\",\"result\":1,\"id\":\"1\"}]")] + [TestCase("[{\"jsonrpc\":\"2.0\",\"error\":\"bad\",\"id\":1}]")] + [TestCase("[{\"jsonrpc\":\"2.0\",\"error\":{},\"id\":1}]")] + [TestCase("[{\"jsonrpc\":\"2.0\",\"error\":{\"code\":\"-1\",\"message\":\"bad\"},\"id\":1}]")] + [TestCase("[{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-1,\"message\":7},\"id\":1}]")] + public async Task MalformedResponse_ThrowsAndTerminatesEveryQueuedTask(string response) + { + // Arrange + var (client, _) = Make(response); + var batch = client.CreateBatch(); + var call = batch.GetSlotAsync(); + + // Act + var act = () => batch.ExecuteAsync(); + + // Assert + await act.Should().ThrowAsync(); + call.IsCompleted.Should().BeTrue(); + var callAct = async () => await call; + await callAct.Should().ThrowAsync(); + } + + [Test] + public async Task NullErrorAlongsideResult_IsTreatedAsAbsent() + { + // Arrange + var (client, _) = Make("""[{"jsonrpc":"2.0","result":7,"error":null,"id":1}]"""); + var batch = client.CreateBatch(); + var call = batch.GetSlotAsync(); + + // Act + await batch.ExecuteAsync(); + + // Assert + (await call).Should().Be(7ul); + } + + [Test] + public async Task DuplicateResponseId_ThrowsAndTerminatesEveryQueuedTask() + { + // Arrange + var (client, _) = Make( + """ + [ + {"jsonrpc":"2.0","result":1,"id":1}, + {"jsonrpc":"2.0","result":2,"id":1} + ] + """); + var batch = client.CreateBatch(); + var first = batch.GetSlotAsync(); + var second = batch.GetSlotAsync(); + + // Act + var act = () => batch.ExecuteAsync(); + + // Assert + await act.Should().ThrowAsync(); + var firstAct = async () => await first; + var secondAct = async () => await second; + await firstAct.Should().ThrowAsync(); + await secondAct.Should().ThrowAsync(); + } + [Test] public async Task SendTransaction_IsBatchable() { diff --git a/tests/SolSharp.Rpc.Tests/RpcJsonTests.cs b/tests/SolSharp.Rpc.Tests/RpcJsonTests.cs index 5357ffd..a311f57 100644 --- a/tests/SolSharp.Rpc.Tests/RpcJsonTests.cs +++ b/tests/SolSharp.Rpc.Tests/RpcJsonTests.cs @@ -2,7 +2,10 @@ using FluentAssertions; using NUnit.Framework; using SolSharp.Core.Converters; +using SolSharp.Core.Primitives; +using SolSharp.Rpc.Models; using SolSharp.Rpc.Protocol; +using SolSharp.Rpc.Streaming; namespace SolSharp.Rpc.Tests; @@ -12,20 +15,15 @@ public static class RpcJsonTests public sealed class Options { [Test] - public void IsFrozen() - { - RpcJson.Options.IsReadOnly.Should().BeTrue(); - } + public void IsFrozen() => RpcJson.Options.IsReadOnly.Should().BeTrue(); [Test] - public void ResolvesThroughTheSourceGeneratedContextsOnly() - { + public void ResolvesThroughTheSourceGeneratedContextsOnly() => // Pinning the resolver chain keeps the Native AOT claim honest: a reflection fallback // sneaking in here would still pass every functional test while silently breaking AOT // publishing. CoreJsonContext must be chained because the Rpc generator cannot materialize - // Core's converter-attributed primitives (their converters are internal to SolSharp.Core). + // Core's converter-attributed primitives from another source-generated assembly. RpcJson.Options.TypeInfoResolverChain.Should().Equal(SolanaJsonContext.Default, CoreJsonContext.Default); - } [Test] public void Serialize_UnregisteredType_ThrowsInsteadOfFallingBackToReflection() @@ -38,11 +36,9 @@ public void Serialize_UnregisteredType_ThrowsInsteadOfFallingBackToReflection() } [Test] - public void DropsNullValuedOptionalsWhenWriting() - { + public void DropsNullValuedOptionalsWhenWriting() => // The request configs rely on WhenWritingNull to keep optional wire fields absent. JsonSerializer.Serialize(new CommitmentConfig(), RpcJson.Options).Should().Be("{}"); - } [Test] public void ReadsPropertyNamesCaseInsensitively() @@ -56,6 +52,35 @@ public void ReadsPropertyNamesCaseInsensitively() value.Value.Should().Be(7); } + [Test] + public void ReadsRpcApiVersionFromResponseContext() + { + // Act + var value = JsonSerializer.Deserialize>( + """{"context":{"slot":42,"apiVersion":"3.1.7"},"value":7}""", RpcJson.Options); + + // Assert + value!.Context!.ApiVersion.Should().Be("3.1.7"); + } + + [Test] + public void WritesReportedAccountSpaceThroughTheCustomConverter() + { + // Arrange + var account = new AccountInfo + { + Owner = new PublicKey(new byte[PublicKey.Length]), + Space = 3, + Data = [1, 2, 3] + }; + + // Act + var json = JsonSerializer.Serialize(account, RpcJson.Options); + + // Assert + json.Should().Contain("\"space\":3").And.Contain("\"data\":[\"AQID\",\"base64\"]"); + } + private sealed record Unregistered; } @@ -63,10 +88,7 @@ private sealed record Unregistered; public sealed class TypeInfo { [Test] - public void ReturnsMetadataBoundToTheSharedOptions() - { - RpcJson.TypeInfo().Options.Should().BeSameAs(RpcJson.Options); - } + public void ReturnsMetadataBoundToTheSharedOptions() => RpcJson.TypeInfo().Options.Should().BeSameAs(RpcJson.Options); [Test] public void UnregisteredType_Throws() @@ -80,4 +102,216 @@ public void UnregisteredType_Throws() private sealed record Unregistered; } + + [TestFixture] + public sealed class RpcContextValue + { + [TestCase("{\"value\":7}")] + [TestCase("{\"context\":null,\"value\":7}")] + [TestCase("{\"context\":{},\"value\":7}")] + [TestCase("{\"context\":{\"slot\":1}}")] + public void MissingMandatoryWrapperMember_ThrowsJsonException(string json) + { + // Act + Action act = () => JsonSerializer.Deserialize>( + json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void ExplicitNullableValue_IsPreserved() + { + // Act + var value = JsonSerializer.Deserialize>( + """{"context":{"slot":1},"value":null}""", RpcJson.Options); + + // Assert + value!.Context!.Slot.Should().Be(1); + value.Value.Should().BeNull(); + } + + [Test] + public void ProgrammaticMissingContext_ThrowsInvalidOperationException() + { + // Arrange + var value = new SolSharp.Rpc.Protocol.RpcContextValue(); + + // Act + Action act = () => _ = value.Context; + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class MandatoryStreamingModels + { + [Test] + public void MissingLogsMembers_ThrowsJsonException() + { + // Act + Action act = () => JsonSerializer.Deserialize("{}", RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void MissingSlotMembers_ThrowsJsonException() + { + // Act + Action act = () => JsonSerializer.Deserialize("{}", RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void MissingVoteMembers_ThrowsJsonException() + { + // Act + Action act = () => JsonSerializer.Deserialize("{}", RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{}")] + [TestCase("{\"slot\":1,\"type\":\"futureStage\",\"timestamp\":1}")] + [TestCase("{\"slot\":1,\"type\":\"createdBank\",\"timestamp\":1}")] + [TestCase("{\"slot\":1,\"type\":\"frozen\",\"timestamp\":1}")] + [TestCase("{\"slot\":1,\"type\":\"dead\",\"timestamp\":1}")] + public void MalformedSlotUpdate_ThrowsJsonException(string json) + { + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void MissingRawBlockMembers_ThrowsJsonException() + { + // Act + Action act = () => JsonSerializer.Deserialize("{}", RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void RawBlockWithNeitherBodyNorError_ThrowsJsonException() + { + // Act + Action act = () => JsonSerializer.Deserialize( + """{"slot":1,"block":null,"err":null}""", RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void MissingBlockMembers_ThrowsJsonException() + { + // Act + Action act = () => JsonSerializer.Deserialize("{}", RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void MissingParsedBlockMembers_ThrowsJsonException() + { + // Act + Action act = () => JsonSerializer.Deserialize("{}", RpcJson.Options); + + // Assert + act.Should().Throw(); + } + } + + public static class SignatureNotificationJsonConverter + { + [TestFixture] + public sealed class Read + { + [Test] + public void Null_ThrowsJsonException() + { + // Act + Action act = () => JsonSerializer.Deserialize("null", RpcJson.Options); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class Write + { + [Test] + public void Processed_SerializesPriorCompatibleErrorObject() + { + // Arrange + using var errorDocument = JsonDocument.Parse("""{"InstructionError":[0,"Custom"]}"""); + var notification = new SignatureNotification + { + Kind = SignatureNotificationKind.Processed, + Err = errorDocument.RootElement.Clone() + }; + + // Act + var json = JsonSerializer.Serialize(notification, RpcJson.Options); + + // Assert + json.Should().Be("""{"err":{"InstructionError":[0,"Custom"]}}"""); + } + + [Test] + public void ProcessedSuccess_SerializesMandatoryNullError() + { + // Act + var json = JsonSerializer.Serialize(new SignatureNotification(), RpcJson.Options); + + // Assert + json.Should().Be("""{"err":null}"""); + } + + [Test] + public void Received_SerializesExactUnionString() + { + // Arrange + var notification = new SignatureNotification { Kind = SignatureNotificationKind.Received }; + + // Act + var json = JsonSerializer.Serialize(notification, RpcJson.Options); + + // Assert + json.Should().Be("\"receivedSignature\""); + } + + [Test] + public void ReceivedWithError_ThrowsJsonException() + { + // Arrange + using var errorDocument = JsonDocument.Parse("1"); + var notification = new SignatureNotification + { + Kind = SignatureNotificationKind.Received, + Err = errorDocument.RootElement.Clone() + }; + + // Act + Action act = () => JsonSerializer.Serialize(notification, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + } + } } diff --git a/tests/SolSharp.Rpc.Tests/RpcRequestsTests.cs b/tests/SolSharp.Rpc.Tests/RpcRequestsTests.cs index 5641339..183059c 100644 --- a/tests/SolSharp.Rpc.Tests/RpcRequestsTests.cs +++ b/tests/SolSharp.Rpc.Tests/RpcRequestsTests.cs @@ -52,16 +52,10 @@ public void BuildsMethodAndCommitment() public sealed class ParameterlessMethods { [Test] - public void GetHealth_HasEmptyParams() - { - Serialize(RpcRequests.GetHealth()).Should().Contain("\"params\":[]"); - } + public void GetHealth_HasEmptyParams() => Serialize(RpcRequests.GetHealth()).Should().Contain("\"params\":[]"); [Test] - public void GetVersion_SetsMethod() - { - Serialize(RpcRequests.GetVersion()).Should().Contain("\"method\":\"getVersion\""); - } + public void GetVersion_SetsMethod() => Serialize(RpcRequests.GetVersion()).Should().Contain("\"method\":\"getVersion\""); } [TestFixture] diff --git a/tests/SolSharp.Rpc.Tests/ServiceCollectionExtensionsTests.cs b/tests/SolSharp.Rpc.Tests/ServiceCollectionExtensionsTests.cs index 8853973..a6fea7c 100644 --- a/tests/SolSharp.Rpc.Tests/ServiceCollectionExtensionsTests.cs +++ b/tests/SolSharp.Rpc.Tests/ServiceCollectionExtensionsTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Options; using NUnit.Framework; using Polly; +using SolSharp.Core.Primitives; using SolSharp.Rpc.Streaming; namespace SolSharp.Rpc.Tests; @@ -117,6 +118,22 @@ public void AcceptsValidHttpsEndpoint() act.Should().NotThrow(); } + [Test] + public void RejectsNonPositiveResponseLimit() + { + var services = new ServiceCollection(); + services.AddSolanaRpc(options => + { + options.Endpoint = "https://api.devnet.solana.com"; + options.MaximumResponseContentLength = 0; + }); + var provider = services.BuildServiceProvider(); + + Action act = () => _ = provider.GetRequiredService>().Value; + + act.Should().Throw(); + } + private static ServiceProvider ProviderFor(string endpoint) { var services = new ServiceCollection(); @@ -156,6 +173,38 @@ public async Task RetriesTransientFailure() handler.CallCount.Should().Be(2); } + [Test] + public async Task DoesNotRetryNonIdempotentAirdrop() + { + // Arrange + var handler = new SequenceHandler( + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable), + Json("""{"jsonrpc":"2.0","result":"duplicate","id":1}""")); + + var services = new ServiceCollection(); + services + .AddSolanaRpc( + options => options.Endpoint = "https://node.example", + resilience => + { + resilience.Retry.MaxRetryAttempts = 1; + resilience.Retry.Delay = TimeSpan.Zero; + resilience.Retry.BackoffType = DelayBackoffType.Constant; + resilience.Retry.UseJitter = false; + }) + .ConfigurePrimaryHttpMessageHandler(() => handler); + + var client = services.BuildServiceProvider().GetRequiredService(); + var account = new PublicKey(new byte[PublicKey.Length]); + + // Act + var act = async () => await client.RequestAirdropAsync(account, 1); + + // Assert + await act.Should().ThrowAsync(); + handler.CallCount.Should().Be(1); + } + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; } diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientAccountEncodingTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientAccountEncodingTests.cs new file mode 100644 index 0000000..d2c16b8 --- /dev/null +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientAccountEncodingTests.cs @@ -0,0 +1,558 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; +using SolSharp.Rpc.Models; +using SolSharp.Rpc.Protocol; + +namespace SolSharp.Rpc.Tests; + +public static class SolanaRpcClientAccountEncodingTests +{ + private const string Address = "11111111111111111111111111111111"; + private const string TokenProgram = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + + private static (SolanaRpcClient Client, FakeHttpMessageHandler Handler) Make(string resultJson) + { + var handler = new FakeHttpMessageHandler( + $$"""{"jsonrpc":"2.0","result":{{resultJson}},"id":1}"""); + var http = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }; + return (new SolanaRpcClient(http), handler); + } + + private static string Account(string data) => + """{"lamports":42,"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":9,"space":3,"data":__DATA__}""" + .Replace("__DATA__", data); + + private static string Contextual(string account) => + """{"context":{"slot":7},"value":__ACCOUNT__}""" + .Replace("__ACCOUNT__", account); + + private static string ContextualAccount(string data) => Contextual(Account(data)); + + private static string KeyedAccount(string data) => + """{"pubkey":"11111111111111111111111111111111","account":__ACCOUNT__}""" + .Replace("__ACCOUNT__", Account(data)); + + [TestFixture] + public sealed class GetAccountInfoWithOptionsAndContextAsync + { + [Test] + public async Task ContextMethod_PreservesSlotAndExactDataBranch() + { + // Arrange + var (client, handler) = Make(ContextualAccount("[\"AQID\",\"base64\"]")); + + // Act + var result = await client.GetAccountInfoWithOptionsAndContextAsync( + PublicKey.Parse(Address), + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Base64, MinContextSlot = 6 }); + + // Assert + result.Context!.Slot.Should().Be(7); + result.Value!.Data.Should().BeOfType(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getAccountInfo","params":["11111111111111111111111111111111",{"encoding":"base64","minContextSlot":6}]}"""); + } + } + + [TestFixture] + public sealed class GetAccountInfoWithOptionsAsync + { + [Test] + public async Task Binary_ParsesLegacyBareStringAndSendsExactWireName() + { + // Arrange + var (client, handler) = Make(ContextualAccount("\"3Mc6vR\"")); + + // Act + var account = await client.GetAccountInfoWithOptionsAsync( + PublicKey.Parse(Address), + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Binary }); + + // Assert + account!.Data.Should().BeOfType() + .Which.EncodedData.Should().Be("3Mc6vR"); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getAccountInfo","params":["11111111111111111111111111111111",{"encoding":"binary"}]}"""); + } + + [TestCase(RpcAccountEncoding.Base58, "base58", "3Mc6vR")] + [TestCase(RpcAccountEncoding.Base64, "base64", "AQID")] + [TestCase(RpcAccountEncoding.Base64Zstd, "base64+zstd", "KLUv_Q")] + public async Task ExplicitEncoding_ParsesTaggedTuple( + RpcAccountEncoding requestedEncoding, + string wireEncoding, + string encodedData) + { + // Arrange + var (client, handler) = Make(ContextualAccount($$"""["{{encodedData}}","{{wireEncoding}}"]""")); + + // Act + var account = await client.GetAccountInfoWithOptionsAsync( + PublicKey.Parse(Address), + new RpcAccountInfoOptions { Encoding = requestedEncoding }); + + // Assert + var data = account!.Data.Should().BeOfType().Which; + data.Encoding.Should().Be(requestedEncoding); + data.EncodedData.Should().Be(encodedData); + var serializedWireEncoding = wireEncoding.Replace("+", "\\u002B"); + handler.CapturedRequestBody.Should().Contain($"\"encoding\":\"{serializedWireEncoding}\""); + } + + [Test] + public async Task JsonParsed_ParsesProgramSpecificPayloadWithoutProjection() + { + // Arrange + const string parsedData = + """{"program":"spl-token","parsed":{"type":"mint","info":{"decimals":6}},"space":82}"""; + var (client, handler) = Make(ContextualAccount(parsedData)); + + // Act + var account = await client.GetAccountInfoWithOptionsAsync( + PublicKey.Parse(Address), + new RpcAccountInfoOptions + { + Encoding = RpcAccountEncoding.JsonParsed, + Commitment = Commitment.Finalized, + DataSlice = new DataSlice(0, 8), + MinContextSlot = 6 + }); + + // Assert + var data = account!.Data.Should().BeOfType().Which; + data.Program.Should().Be("spl-token"); + data.Space.Should().Be(82); + data.Value.GetProperty("info").GetProperty("decimals").GetInt32().Should().Be(6); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getAccountInfo","params":["11111111111111111111111111111111",{"encoding":"jsonParsed","commitment":"finalized","dataSlice":{"offset":0,"length":8},"minContextSlot":6}]}"""); + } + + [Test] + public async Task JsonParsedUnknownProgram_PreservesUpstreamBase64Fallback() + { + // Arrange + var (client, _) = Make(ContextualAccount("[\"AQID\",\"base64\"]")); + + // Act + var account = await client.GetAccountInfoWithOptionsAsync( + PublicKey.Parse(Address), + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.JsonParsed }); + + // Assert + var data = account!.Data.Should().BeOfType().Which; + data.Encoding.Should().Be(RpcAccountEncoding.Base64); + data.EncodedData.Should().Be("AQID"); + } + + [Test] + public async Task UnsetEncoding_OmitsFieldAndAcceptsMethodDefaultBranch() + { + // Arrange + var (client, handler) = Make(ContextualAccount("\"3Mc6vR\"")); + + // Act + var account = await client.GetAccountInfoWithOptionsAsync( + PublicKey.Parse(Address), + new RpcAccountInfoOptions { MinContextSlot = 6 }); + + // Assert + account!.Data.Should().BeOfType(); + handler.CapturedRequestBody.Should().NotContain("encoding"); + handler.CapturedRequestBody.Should().Contain("\"minContextSlot\":6"); + } + + [Test] + public async Task UnknownRequestedEncoding_ThrowsBeforeTransport() + { + // Arrange + var (client, handler) = Make("null"); + var options = new RpcAccountInfoOptions { Encoding = (RpcAccountEncoding)int.MaxValue }; + + // Act + var act = async () => await client.GetAccountInfoWithOptionsAsync(PublicKey.Parse(Address), options); + + // Assert + await act.Should().ThrowAsync(); + handler.CapturedRequestBody.Should().BeNull(); + } + + [TestCase("null")] + [TestCase("[]")] + [TestCase("[\"AQID\"]")] + [TestCase("[\"AQID\",\"unknown\"]")] + [TestCase("[\"AQID\",1]")] + [TestCase("{}")] + [TestCase("{\"program\":\"spl-token\",\"parsed\":{},\"space\":-1}")] + [TestCase("{\"program\":1,\"parsed\":{},\"space\":82}")] + public async Task MalformedAccountData_ThrowsJsonException(string data) + { + // Arrange + var (client, _) = Make(ContextualAccount(data)); + + // Act + var act = async () => await client.GetAccountInfoWithOptionsAsync( + PublicKey.Parse(Address), new RpcAccountInfoOptions()); + + // Assert + await act.Should().ThrowAsync(); + } + + [TestCase("lamports", "\"lamports\":42,")] + [TestCase("data", ",\"data\":[\"AQID\",\"base64\"]")] + [TestCase("owner", "\"owner\":\"11111111111111111111111111111111\",")] + [TestCase("executable", "\"executable\":false,")] + [TestCase("rentEpoch", "\"rentEpoch\":9,")] + public async Task OmittedMandatoryAccountField_ThrowsJsonException( + string omittedField, + string propertyFragment) + { + // Arrange + var malformed = Account("[\"AQID\",\"base64\"]") + .Replace(propertyFragment, string.Empty, StringComparison.Ordinal); + var (client, _) = Make(Contextual(malformed)); + + // Act + var act = async () => await client.GetAccountInfoWithOptionsAsync( + PublicKey.Parse(Address), + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Base64 }); + + // Assert + await act.Should().ThrowAsync().WithMessage($"*{omittedField}*"); + } + + [Test] + public async Task OmittedOptionalSpace_ParsesWithNullSpace() + { + // Arrange + var withoutSpace = Account("[\"AQID\",\"base64\"]") + .Replace("\"space\":3,", string.Empty, StringComparison.Ordinal); + var (client, _) = Make(Contextual(withoutSpace)); + + // Act + var account = await client.GetAccountInfoWithOptionsAsync( + PublicKey.Parse(Address), + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Base64 }); + + // Assert + account.Should().NotBeNull(); + account!.Lamports.Should().Be(42); + account.Owner.Should().Be(PublicKey.Parse(Address)); + account.Executable.Should().BeFalse(); + account.RentEpoch.Should().Be(9); + account.Data.Should().BeOfType(); + account.Space.Should().BeNull(); + } + } + + [TestFixture] + public sealed class GetMultipleAccountsWithOptionsAndContextAsync + { + [Test] + public async Task ContextMethod_PreservesSlotAndMissingEntries() + { + // Arrange + var resultJson = $$"""{"context":{"slot":7},"value":[null,{{Account("[\"AQID\",\"base64\"]")}}]}"""; + var (client, handler) = Make(resultJson); + + // Act + var result = await client.GetMultipleAccountsWithOptionsAndContextAsync( + [PublicKey.Parse(Address), PublicKey.Parse(Address)], + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Base64 }); + + // Assert + result.Context!.Slot.Should().Be(7); + result.Value.Should().HaveCount(2); + result.Value![0].Should().BeNull(); + handler.CapturedRequestBody.Should().Contain("\"encoding\":\"base64\""); + } + } + + [TestFixture] + public sealed class GetMultipleAccountsWithOptionsAsync + { + [Test] + public async Task PreservesNullEntriesAndExactTupleBranches() + { + // Arrange + var result = $$"""{"context":{"slot":7},"value":[null,{{Account("[\"AQID\",\"base64\"]")}}]}"""; + var (client, handler) = Make(result); + + // Act + var accounts = await client.GetMultipleAccountsWithOptionsAsync( + [PublicKey.Parse(Address), PublicKey.Parse(Address)], + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Base64 }); + + // Assert + accounts.Should().HaveCount(2); + accounts[0].Should().BeNull(); + accounts[1]!.Data.Should().BeOfType() + .Which.Encoding.Should().Be(RpcAccountEncoding.Base64); + handler.CapturedRequestBody.Should().Contain("\"encoding\":\"base64\""); + } + } + + [TestFixture] + public sealed class GetProgramAccountsWithOptionsAndContextAsync + { + [Test] + public async Task ContextMethod_ForcesUpstreamContextShape() + { + // Arrange + var resultJson = $$"""{"context":{"slot":9},"value":[{{KeyedAccount("[\"AQID\",\"base64\"]")}}]}"""; + var (client, handler) = Make(resultJson); + + // Act + var result = await client.GetProgramAccountsWithOptionsAndContextAsync( + PublicKey.Parse(TokenProgram), + new RpcProgramAccountsOptions { Encoding = RpcAccountEncoding.Base64, WithContext = false }); + + // Assert + result.Context!.Slot.Should().Be(9); + result.Value.Should().ContainSingle(); + handler.CapturedRequestBody.Should().Contain("\"withContext\":true"); + handler.CapturedRequestBody.Should().Contain("\"encoding\":\"base64\""); + } + } + + [TestFixture] + public sealed class GetProgramAccountsWithOptionsAsync + { + [Test] + public async Task ContextShape_ParsesKeyedParsedAccountAndSendsAllOptions() + { + // Arrange + const string parsedData = """{"program":"spl-token","parsed":{"type":"mint"},"space":82}"""; + var result = $$"""{"context":{"slot":9},"value":[{{KeyedAccount(parsedData)}}]}"""; + var (client, handler) = Make(result); + + // Act + var accounts = await client.GetProgramAccountsWithOptionsAsync( + PublicKey.Parse(TokenProgram), + new RpcProgramAccountsOptions + { + Encoding = RpcAccountEncoding.JsonParsed, + Commitment = Commitment.Finalized, + Filters = [AccountFilter.DataSize(82)], + DataSlice = new DataSlice(0, 8), + MinContextSlot = 8, + WithContext = true, + SortResults = false + }); + + // Assert + accounts.Should().ContainSingle(); + accounts[0].Account.Data.Should().BeOfType(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getProgramAccounts","params":["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",{"encoding":"jsonParsed","commitment":"finalized","minContextSlot":8,"dataSlice":{"offset":0,"length":8},"filters":[{"dataSize":82}],"withContext":true,"sortResults":false}]}"""); + } + + [Test] + public async Task BareShape_ParsesWithoutContext() + { + // Arrange + var result = $$"""[{{KeyedAccount("[\"3Mc6vR\",\"base58\"]")}}]"""; + var (client, _) = Make(result); + + // Act + var accounts = await client.GetProgramAccountsWithOptionsAsync( + PublicKey.Parse(TokenProgram), + new RpcProgramAccountsOptions { Encoding = RpcAccountEncoding.Base58 }); + + // Assert + accounts.Should().ContainSingle(); + accounts[0].Account.Data.Should().BeOfType() + .Which.Encoding.Should().Be(RpcAccountEncoding.Base58); + } + + [Test] + public async Task ExplicitNullAccount_ThrowsJsonException() + { + // Arrange + var result = $$"""[{"pubkey":"{{Address}}","account":null}]"""; + var (client, _) = Make(result); + + // Act + var act = async () => await client.GetProgramAccountsWithOptionsAsync( + PublicKey.Parse(TokenProgram), + new RpcProgramAccountsOptions { Encoding = RpcAccountEncoding.Base64 }); + + // Assert + await act.Should().ThrowAsync().WithMessage("*non-null account*"); + } + + [Test] + public async Task NullKeyedAccountEntry_ThrowsJsonException() + { + // Arrange + var (client, _) = Make("[null]"); + + // Act + var act = async () => await client.GetProgramAccountsWithOptionsAsync( + PublicKey.Parse(TokenProgram), + new RpcProgramAccountsOptions { Encoding = RpcAccountEncoding.Base64 }); + + // Assert + await act.Should().ThrowAsync().WithMessage("*cannot contain null entries*"); + } + } + + [TestFixture] + public sealed class GetTokenAccountsByOwnerWithOptionsAndContextAsync + { + [Test] + public async Task OwnerContextMethod_PreservesMandatoryContext() + { + // Arrange + var resultJson = $$"""{"context":{"slot":9},"value":[{{KeyedAccount("[\"AQID\",\"base64\"]")}}]}"""; + var (client, handler) = Make(resultJson); + + // Act + var result = await client.GetTokenAccountsByOwnerWithOptionsAndContextAsync( + PublicKey.Parse(Address), + TokenAccountsFilter.ByProgramId(PublicKey.Parse(TokenProgram)), + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Base64 }); + + // Assert + result.Context!.Slot.Should().Be(9); + result.Value.Should().ContainSingle(); + handler.CapturedRequestBody.Should().Contain("\"programId\":\"Tokenkeg"); + handler.CapturedRequestBody.Should().Contain("\"encoding\":\"base64\""); + } + } + + [TestFixture] + public sealed class GetTokenAccountsByDelegateWithOptionsAndContextAsync + { + [Test] + public async Task DelegateContextMethod_PreservesMandatoryContext() + { + // Arrange + var resultJson = $$"""{"context":{"slot":10},"value":[{{KeyedAccount("[\"3Mc6vR\",\"base58\"]")}}]}"""; + var (client, handler) = Make(resultJson); + + // Act + var result = await client.GetTokenAccountsByDelegateWithOptionsAndContextAsync( + PublicKey.Parse(Address), + TokenAccountsFilter.ByMint(PublicKey.Parse(Address)), + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Base58 }); + + // Assert + result.Context!.Slot.Should().Be(10); + result.Value.Should().ContainSingle(); + handler.CapturedRequestBody.Should().Contain("\"mint\":\"11111111111111111111111111111111\""); + handler.CapturedRequestBody.Should().Contain("\"encoding\":\"base58\""); + } + } + + [TestFixture] + public sealed class GetTokenAccountsByOwnerWithOptionsAsync + { + [Test] + public async Task OwnerPath_SendsProgramFilterAndParsesBase64Zstd() + { + // Arrange + var result = $$"""{"context":{"slot":9},"value":[{{KeyedAccount("[\"KLUv_Q\",\"base64+zstd\"]")}}]}"""; + var (client, handler) = Make(result); + + // Act + var accounts = await client.GetTokenAccountsByOwnerWithOptionsAsync( + PublicKey.Parse(Address), + TokenAccountsFilter.ByProgramId(PublicKey.Parse(TokenProgram)), + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Base64Zstd }); + + // Assert + accounts.Should().ContainSingle(); + accounts[0].Account.Data.Should().BeOfType() + .Which.Encoding.Should().Be(RpcAccountEncoding.Base64Zstd); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getTokenAccountsByOwner","params":["11111111111111111111111111111111",{"programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},{"encoding":"base64\u002Bzstd"}]}"""); + } + } + + [TestFixture] + public sealed class GetTokenAccountsByDelegateWithOptionsAsync + { + [Test] + public async Task DelegatePath_SendsMintFilterAndParsesLegacyBinary() + { + // Arrange + var result = $$"""{"context":{"slot":9},"value":[{{KeyedAccount("\"3Mc6vR\"")}}]}"""; + var (client, handler) = Make(result); + + // Act + var accounts = await client.GetTokenAccountsByDelegateWithOptionsAsync( + PublicKey.Parse(Address), + TokenAccountsFilter.ByMint(PublicKey.Parse(Address)), + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Binary }); + + // Assert + accounts.Should().ContainSingle(); + accounts[0].Account.Data.Should().BeOfType(); + handler.CapturedRequestBody.Should().Contain("\"mint\":\"11111111111111111111111111111111\""); + handler.CapturedRequestBody.Should().Contain("\"encoding\":\"binary\""); + } + } +} + +public static class RpcAccountDataJsonConverterTests +{ + private static string Account(string data) => + """{"lamports":42,"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":9,"space":3,"data":__DATA__}""" + .Replace("__DATA__", data); + + [TestFixture] + public sealed class Write + { + [Test] + public void NullBranch_ThrowsJsonExceptionWhenWritten() + { + // Act + var act = () => JsonSerializer.Serialize(null!, RpcJson.Options); + + // Assert + act.Should().Throw().WithMessage("Account data cannot be null."); + } + + [Test] + public void SourceGeneratedRoundTrip_PreservesParsedPayload() + { + // Arrange + var json = Account("{\"program\":\"spl-token\",\"parsed\":null,\"space\":82}"); + var account = JsonSerializer.Deserialize(json, RpcJson.Options)!; + + // Act + var roundTrip = JsonSerializer.Serialize(account, RpcJson.Options); + + // Assert + account.Data.Should().BeOfType().Which.Value.ValueKind + .Should().Be(JsonValueKind.Null); + roundTrip.Should().Contain("\"data\":{\"program\":\"spl-token\",\"parsed\":null,\"space\":82}"); + } + } + + [TestFixture] + public sealed class Read + { + [Test] + public void ExternalConsumerContext_ResolvesPublicConverterAndUnion() + { + // Arrange + var json = Account("[\"AQID\",\"base64\"]"); + + // Act + var account = JsonSerializer.Deserialize(json, ConsumerAccountJsonContext.Default.RpcAccountInfo)!; + var roundTrip = JsonSerializer.Serialize(account, ConsumerAccountJsonContext.Default.RpcAccountInfo); + + // Assert + account.Data.Should().BeOfType().Which.Encoding + .Should().Be(RpcAccountEncoding.Base64); + roundTrip.Should().Contain("\"data\":[\"AQID\",\"base64\"]"); + } + } +} + +[JsonSerializable(typeof(RpcAccountInfo))] +internal sealed partial class ConsumerAccountJsonContext : JsonSerializerContext; diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientAccountTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientAccountTests.cs index afff36f..f244aba 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientAccountTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientAccountTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using FluentAssertions; using NUnit.Framework; using SolSharp.Core.Primitives; @@ -41,6 +42,7 @@ public async Task ParsesTheAccountAndDecodesBase64Data() info.Owner.Should().Be(PublicKey.Parse(OwnerBase58)); info.Executable.Should().BeFalse(); info.RentEpoch.Should().Be(ulong.MaxValue); + info.Space.Should().Be(3); info.Data.Should().Equal(expectedData); handler.CapturedRequestBody.Should().Contain("\"getAccountInfo\""); @@ -73,6 +75,42 @@ public async Task SendsDataSlice() // Assert handler.CapturedRequestBody.Should().Contain("\"dataSlice\":{\"offset\":8,\"length\":32}"); } + + [TestCase("[\"AQID\"]")] + [TestCase("[\"AQID\",\"base58\"]")] + [TestCase("[\"AQID\",\"base64\",\"extra\"]")] + [TestCase("{\"bytes\":\"AQID\"}")] + public async Task MalformedOrUnsupportedDataTuple_ThrowsJsonException(string data) + { + var value = + """{"data":__DATA__,"executable":false,"lamports":1,"owner":"11111111111111111111111111111111","rentEpoch":0}""" + .Replace("__DATA__", data); + var (client, _) = Make(ContextEnvelope(value)); + + Func act = async () => await client.GetAccountInfoAsync(PublicKey.Parse(OwnerBase58)); + + await act.Should().ThrowAsync(); + } + + [TestCase("\"oops\"")] + [TestCase("true")] + [TestCase("{}")] + [TestCase("-1")] + [TestCase("18446744073709551616")] + public async Task PresentSpaceOutsideOptionalU64_ThrowsJsonException(string space) + { + // Arrange + var value = + """{"data":["AQID","base64"],"executable":false,"lamports":1,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":__SPACE__}""" + .Replace("__SPACE__", space, StringComparison.Ordinal); + var (client, _) = Make(ContextEnvelope(value)); + + // Act + var act = async () => await client.GetAccountInfoAsync(PublicKey.Parse(OwnerBase58)); + + // Assert + await act.Should().ThrowAsync(); + } } [TestFixture] diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientChainTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientChainTests.cs index 0f91214..c8fd8eb 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientChainTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientChainTests.cs @@ -66,18 +66,47 @@ public async Task ParsesAccounts() { // Arrange var (client, handler) = Make( - """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[{"address":"11111111111111111111111111111111","amount":"500","decimals":6,"uiAmountString":"0.0005"}]},"id":1}"""); + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[{"address":"11111111111111111111111111111111","amount":"500","decimals":6,"uiAmount":0.0005,"uiAmountString":"0.0005"}]},"id":1}"""); // Act var accounts = await client.GetTokenLargestAccountsAsync(PublicKey.Parse(TokenProgram)); // Assert accounts.Should().ContainSingle(); + accounts[0].UiAmount.Should().Be(0.0005d); accounts[0].Address.Should().Be(PublicKey.Parse(SystemProgram)); accounts[0].Amount.Should().Be("500"); accounts[0].Decimals.Should().Be(6); handler.CapturedRequestBody.Should().Contain("\"getTokenLargestAccounts\""); } + + [Test] + public async Task MissingMandatoryAmountFields_ThrowsJsonException() + { + // Arrange + var (client, _) = Make( + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[{}]},"id":1}"""); + + // Act + var act = async () => await client.GetTokenLargestAccountsAsync(PublicKey.Parse(TokenProgram)); + + // Assert + await act.Should().ThrowAsync(); + } + + [Test] + public async Task NullEntry_ThrowsJsonException() + { + // Arrange + var (client, _) = Make( + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[null]},"id":1}"""); + + // Act + var act = async () => await client.GetTokenLargestAccountsAsync(PublicKey.Parse(TokenProgram)); + + // Assert + await act.Should().ThrowAsync(); + } } [TestFixture] @@ -88,7 +117,7 @@ public async Task ParsesBlock() { // Arrange var (client, handler) = Make( - """{"jsonrpc":"2.0","result":{"blockhash":"Ckt","previousBlockhash":"Prev","parentSlot":99,"blockHeight":90,"blockTime":1700000000,"signatures":["sig1","sig2"]},"id":1}"""); + """{"jsonrpc":"2.0","result":{"blockhash":"Ckt","previousBlockhash":"Prev","parentSlot":99,"blockHeight":90,"blockTime":1700000000,"numRewardPartitions":8,"signatures":["sig1","sig2"]},"id":1}"""); // Act var block = await client.GetBlockAsync(100); @@ -100,6 +129,7 @@ public async Task ParsesBlock() block.ParentSlot.Should().Be(99); block.BlockHeight.Should().Be(90); block.BlockTime.Should().Be(1700000000); + block.NumRewardPartitions.Should().Be(8); block.Signatures.Should().Equal("sig1", "sig2"); handler.CapturedRequestBody.Should().Contain("\"getBlock\""); handler.CapturedRequestBody.Should().Contain("\"transactionDetails\":\"signatures\""); @@ -117,5 +147,38 @@ public async Task ReturnsNullForSkippedSlot() // Assert block.Should().BeNull(); } + + [Test] + public async Task MissingMandatoryBlockFields_ThrowsJsonException() + { + // Arrange + var (client, _) = Make("""{"jsonrpc":"2.0","result":{},"id":1}"""); + + // Act + var act = async () => await client.GetBlockAsync(100); + + // Assert + await act.Should().ThrowAsync(); + } + } + + [TestFixture] + public sealed class GetBlockWithMaxVersionAsync + { + [Test] + public async Task ExplicitVersionOptIn_SendsVersionOne() + { + // Arrange + var (client, handler) = Make( + """{"jsonrpc":"2.0","result":{"blockhash":"Ckt","previousBlockhash":"Prev","parentSlot":99,"blockHeight":null,"blockTime":null,"signatures":[]},"id":1}"""); + + // Act + var block = await client.GetBlockWithMaxVersionAsync(100, maxSupportedTransactionVersion: 1); + + // Assert + block.Should().NotBeNull(); + handler.CapturedRequestBody.Should().Contain("\"transactionDetails\":\"signatures\""); + handler.CapturedRequestBody.Should().Contain("\"maxSupportedTransactionVersion\":1"); + } } } diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientClusterReadsTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientClusterReadsTests.cs index c0d45ba..e959486 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientClusterReadsTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientClusterReadsTests.cs @@ -1,6 +1,8 @@ +using System.Text.Json; using FluentAssertions; using NUnit.Framework; using SolSharp.Core.Primitives; +using SolSharp.Rpc.Models; namespace SolSharp.Rpc.Tests; @@ -35,11 +37,41 @@ public async Task ParsesCurrentAndDelinquent() current.NodePubkey.Should().Be(PublicKey.Parse(Node)); current.ActivatedStake.Should().Be(42000000ul); current.Commission.Should().Be(7); + current.InflationRewardsCommissionBps.Should().Be(725); current.LastVote.Should().Be(250000ul); current.RootSlot.Should().Be(249968ul); current.EpochVoteAccount.Should().BeTrue(); current.EpochCredits.Should().HaveCount(2); - current.EpochCredits[1].Should().Equal(601L, 2100L, 1000L); + current.EpochCredits[1].Should().Be( + new VoteEpochCredit(ulong.MaxValue, 9223372036854775808UL, ulong.MaxValue)); + } + + [TestCase("[1,2]")] + [TestCase("[1,2,3,4]")] + public async Task MalformedEpochCreditTuple_ThrowsJsonException(string tuple) + { + // Arrange + var malformed = Votes.Replace("[600,1000,900]", tuple, StringComparison.Ordinal); + var (client, _) = Make(malformed); + + // Act + var act = async () => await client.GetVoteAccountsAsync(); + + // Assert + await act.Should().ThrowAsync(); + } + + [Test] + public async Task MissingRequiredFields_ThrowsJsonException() + { + // Arrange + var (client, _) = Make("""{"jsonrpc":"2.0","result":{},"id":1}"""); + + // Act + var act = async () => await client.GetVoteAccountsAsync(); + + // Assert + await act.Should().ThrowAsync(); } } @@ -63,10 +95,25 @@ public async Task ParsesRewardsAndNullEntries() rewards[0]!.Amount.Should().Be(2500ul); rewards[0]!.PostBalance.Should().Be(1002500ul); rewards[0]!.Commission.Should().BeNull(); + rewards[0]!.CommissionBps.Should().Be(725); rewards[1].Should().BeNull(); handler.CapturedRequestBody.Should().Contain("getInflationReward"); handler.CapturedRequestBody.Should().Contain("600"); } + + [Test] + public async Task NullAddresses_ThrowsArgumentNullException() + { + // Arrange + var (client, handler) = Make(Inflation); + + // Act + var act = async () => await client.GetInflationRewardAsync(null!); + + // Assert + await act.Should().ThrowAsync().WithParameterName("addresses"); + handler.CapturedRequestBody.Should().BeNull(); + } } [TestFixture] @@ -84,7 +131,7 @@ public async Task ParsesSchedule() // Assert schedule.Should().NotBeNull(); schedule!.Should().ContainKey(Node); - schedule[Node].Should().Equal(0, 1, 2, 3, 4, 5, 6, 7); + schedule[Node].Should().Equal(0ul, 1ul, 2ul, 3ul, 4ul, 5ul, 6ul, 7ul); } [Test] @@ -133,19 +180,119 @@ public async Task ParsesNodes() var node = nodes.Should().ContainSingle().Subject; node.Pubkey.Should().Be(PublicKey.Parse(Node)); node.Gossip.Should().Be("10.0.0.1:8001"); + node.Tvu.Should().Be("10.0.0.1:8002"); node.Tpu.Should().Be("10.0.0.1:8003"); + node.TpuQuic.Should().Be("10.0.0.1:8004"); + node.TpuForwards.Should().Be("10.0.0.1:8005"); + node.TpuForwardsQuic.Should().Be("10.0.0.1:8006"); + node.TpuVote.Should().Be("10.0.0.1:8007"); + node.ServeRepair.Should().Be("10.0.0.1:8008"); node.Rpc.Should().Be("10.0.0.1:8899"); + node.Pubsub.Should().Be("10.0.0.1:8900"); node.Version.Should().Be("1.18.5"); - node.FeatureSet.Should().Be(3469865029L); - node.ShredVersion.Should().Be(50093); + node.ClientId.Should().Be("agave"); + node.FeatureSet.Should().Be(uint.MaxValue); + node.ShredVersion.Should().Be(ushort.MaxValue); + } + + [Test] + public async Task NullEntry_ThrowsJsonException() + { + // Arrange + var (client, _) = Make("""{"jsonrpc":"2.0","result":[null],"id":1}"""); + + // Act + var act = async () => await client.GetClusterNodesAsync(); + + // Assert + await act.Should().ThrowAsync(); + } + } + + [TestFixture] + public sealed class GetAgGenesisCertificateAsync + { + [Test] + public async Task ParsesCurrentAgaveWireShape() + { + // Arrange: Hash and BLS Signature derive serde over their fixed byte arrays in the pinned + // SDK; unlike the usual RPC hash wrappers, these fields are JSON number arrays. + var blockId = string.Join(',', Enumerable.Range(0, 32)); + var signature = string.Join(',', Enumerable.Repeat(7, 192)); + var response = + """{"jsonrpc":"2.0","result":{"block":{"slot":99,"block_id":[__BLOCK__]},"signature":{"signature":[__SIGNATURE__],"bitmap":[1,128]}},"id":1}""" + .Replace("__BLOCK__", blockId, StringComparison.Ordinal) + .Replace("__SIGNATURE__", signature, StringComparison.Ordinal); + var (client, handler) = Make(response); + + // Act + var certificate = await client.GetAgGenesisCertificateAsync(); + + // Assert + certificate.Should().NotBeNull(); + certificate!.Block.Slot.Should().Be(99); + certificate.Block.BlockId.Should().Equal(Enumerable.Range(0, 32).Select(static value => (byte)value)); + certificate.Signature.Signature.Should().HaveCount(192).And.OnlyContain(value => value == 7); + certificate.Signature.Bitmap.Should().Equal(1, 128); + handler.CapturedRequestBody.Should().Contain("\"method\":\"getAgGenesisCert\""); + } + + [Test] + public async Task ReturnsNullBeforeAlpenglowActivation() + { + // Arrange + var (client, _) = Make("""{"jsonrpc":"2.0","result":null,"id":1}"""); + + // Act & Assert + (await client.GetAgGenesisCertificateAsync()).Should().BeNull(); + } + + [TestCase("{}")] + [TestCase("{\"block\":null,\"signature\":null}")] + [TestCase("{\"block\":{\"slot\":1,\"block_id\":[]},\"signature\":{\"signature\":[],\"bitmap\":[]}}")] + [TestCase("{\"block\":{\"slot\":1,\"block_id\":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},\"signature\":null}")] + public async Task MalformedCertificate_ThrowsJsonException(string result) + { + // Arrange + var response = """{"jsonrpc":"2.0","result":__RESULT__,"id":1}""" + .Replace("__RESULT__", result, StringComparison.Ordinal); + var (client, _) = Make(response); + + // Act + var act = async () => await client.GetAgGenesisCertificateAsync(); + + // Assert + await act.Should().ThrowAsync(); + } + + [Test] + public async Task ValidatorBitmapLongerThanPinnedMaximum_ThrowsJsonException() + { + // Arrange + var blockId = string.Join(',', Enumerable.Repeat(0, 32)); + var signature = string.Join(',', Enumerable.Repeat(0, 192)); + var bitmap = string.Join(',', Enumerable.Repeat(0, 513)); + var result = + """{"block":{"slot":1,"block_id":[__BLOCK__]},"signature":{"signature":[__SIGNATURE__],"bitmap":[__BITMAP__]}}""" + .Replace("__BLOCK__", blockId, StringComparison.Ordinal) + .Replace("__SIGNATURE__", signature, StringComparison.Ordinal) + .Replace("__BITMAP__", bitmap, StringComparison.Ordinal); + var (client, _) = Make("""{"jsonrpc":"2.0","result":__RESULT__,"id":1}""" + .Replace("__RESULT__", result, StringComparison.Ordinal)); + + // Act + var act = async () => await client.GetAgGenesisCertificateAsync(); + + // Assert + await act.Should().ThrowAsync(); } } private const string Votes = - """{"jsonrpc":"2.0","result":{"current":[{"votePubkey":"9jLkNAaW9E47LQMHvjohy2uAAyr1331bAxgJKFRU7wF6","nodePubkey":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","activatedStake":42000000,"epochVoteAccount":true,"commission":7,"lastVote":250000,"rootSlot":249968,"epochCredits":[[600,1000,900],[601,2100,1000]]}],"delinquent":[]},"id":1}"""; + """{"jsonrpc":"2.0","result":{"current":[{"votePubkey":"9jLkNAaW9E47LQMHvjohy2uAAyr1331bAxgJKFRU7wF6","nodePubkey":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","activatedStake":42000000,"epochVoteAccount":true,"commission":7,"inflationRewardsCommissionBps":725,"lastVote":250000,"rootSlot":249968,"epochCredits":[[600,1000,900],[18446744073709551615,9223372036854775808,18446744073709551615]]}],"delinquent":[]},"id":1}"""; private const string Inflation = - """{"jsonrpc":"2.0","result":[{"epoch":600,"effectiveSlot":259200000,"amount":2500,"postBalance":1002500,"commission":null},null],"id":1}"""; + """{"jsonrpc":"2.0","result":[{"epoch":600,"effectiveSlot":259200000,"amount":2500,"postBalance":1002500,"commission":null,"commissionBps":725},null],"id":1}"""; private const string Schedule = """{"jsonrpc":"2.0","result":{"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67":[0,1,2,3,4,5,6,7]},"id":1}"""; @@ -155,5 +302,5 @@ public async Task ParsesNodes() private const string Blocks = """{"jsonrpc":"2.0","result":[100,101,103,104],"id":1}"""; private const string Nodes = - """{"jsonrpc":"2.0","result":[{"pubkey":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","gossip":"10.0.0.1:8001","tpu":"10.0.0.1:8003","rpc":"10.0.0.1:8899","version":"1.18.5","featureSet":3469865029,"shredVersion":50093}],"id":1}"""; + """{"jsonrpc":"2.0","result":[{"pubkey":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","gossip":"10.0.0.1:8001","tvu":"10.0.0.1:8002","tpu":"10.0.0.1:8003","tpuQuic":"10.0.0.1:8004","tpuForwards":"10.0.0.1:8005","tpuForwardsQuic":"10.0.0.1:8006","tpuVote":"10.0.0.1:8007","serveRepair":"10.0.0.1:8008","rpc":"10.0.0.1:8899","pubsub":"10.0.0.1:8900","version":"1.18.5","clientId":"agave","featureSet":4294967295,"shredVersion":65535}],"id":1}"""; } diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientConfigParityTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientConfigParityTests.cs new file mode 100644 index 0000000..1615caf --- /dev/null +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientConfigParityTests.cs @@ -0,0 +1,724 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc.Tests; + +public static class SolanaRpcClientConfigParityTests +{ + private const string Address = "11111111111111111111111111111111"; + private const string Program = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + + private static (SolanaRpcClient Client, FakeHttpMessageHandler Handler) Make(string resultJson) + { + var handler = new FakeHttpMessageHandler( + $$"""{"jsonrpc":"2.0","result":{{resultJson}},"id":1}"""); + var http = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }; + return (new SolanaRpcClient(http), handler); + } + + private static RpcContextOptions ContextOptions() => new() + { + Commitment = Commitment.Finalized, + MinContextSlot = 42 + }; + + [TestFixture] + public sealed class GetLatestBlockhashWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendExactPinnedConfig() + { + // Arrange + var (client, handler) = Make( + """{"context":{"slot":42},"value":{"blockhash":"abc","lastValidBlockHeight":99}}"""); + + // Act + var value = await client.GetLatestBlockhashWithOptionsAsync(ContextOptions()); + + // Assert + value.LastValidBlockHeight.Should().Be(99); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getLatestBlockhash","params":[{"commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class GetBalanceWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendExactPinnedConfig() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":42},"value":7}"""); + + // Act + var value = await client.GetBalanceWithOptionsAsync(PublicKey.Parse(Address), ContextOptions()); + + // Assert + value.Should().Be(7); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getBalance","params":["11111111111111111111111111111111",{"commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class GetSlotWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendExactPinnedConfig() + { + // Arrange + var (client, handler) = Make("7"); + + // Act + var value = await client.GetSlotWithOptionsAsync(ContextOptions()); + + // Assert + value.Should().Be(7); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getSlot","params":[{"commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class GetBlockHeightWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendExactPinnedConfig() + { + // Arrange + var (client, handler) = Make("8"); + + // Act + var value = await client.GetBlockHeightWithOptionsAsync(ContextOptions()); + + // Assert + value.Should().Be(8); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getBlockHeight","params":[{"commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class GetTransactionCountWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendExactPinnedConfig() + { + // Arrange + var (client, handler) = Make("9"); + + // Act + var value = await client.GetTransactionCountWithOptionsAsync(ContextOptions()); + + // Assert + value.Should().Be(9); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getTransactionCount","params":[{"commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class GetAccountInfoWithContextAsync + { + [Test] + public async Task FullConfig_ParsesContextAndSendsExactJson() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":55,"apiVersion":"3.0"},"value":null}"""); + var options = new GetAccountInfoOptions + { + Commitment = Commitment.Processed, + DataSlice = new DataSlice(3, 5), + MinContextSlot = 50 + }; + + // Act + var result = await client.GetAccountInfoWithContextAsync(PublicKey.Parse(Address), options); + + // Assert + result.Context!.Slot.Should().Be(55); + result.Context.ApiVersion.Should().Be("3.0"); + result.Value.Should().BeNull(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getAccountInfo","params":["11111111111111111111111111111111",{"encoding":"base64","commitment":"processed","dataSlice":{"offset":3,"length":5},"minContextSlot":50}]}"""); + } + + [Test] + public async Task DataSlicePreservesFullUpstreamUnsignedWidths() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":55},"value":null}"""); + var options = new GetAccountInfoOptions + { + DataSlice = new DataSlice(2147483648UL, ulong.MaxValue) + }; + + // Act + await client.GetAccountInfoWithContextAsync(PublicKey.Parse(Address), options); + + // Assert + handler.CapturedRequestBody.Should().Contain( + "\"dataSlice\":{\"offset\":2147483648,\"length\":18446744073709551615}"); + } + } + + [TestFixture] + public sealed class GetAccountInfoWithOptionsAsync + { + [Test] + public async Task FullConfig_ReturnsValuePath() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":55},"value":null}"""); + + // Act + var result = await client.GetAccountInfoWithOptionsAsync( + PublicKey.Parse(Address), + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Binary, MinContextSlot = 50 }); + + // Assert + result.Should().BeNull(); + handler.CapturedRequestBody.Should().Contain("\"encoding\":\"binary\""); + handler.CapturedRequestBody.Should().Contain("\"minContextSlot\":50"); + } + } + + [TestFixture] + public sealed class GetMultipleAccountsWithContextAsync + { + [Test] + public async Task FullConfig_ParsesContextAndPreservesNullEntries() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":77},"value":[null]}"""); + var options = new GetAccountInfoOptions + { + Commitment = Commitment.Confirmed, + DataSlice = new DataSlice(1, 2), + MinContextSlot = 70 + }; + + // Act + var result = await client.GetMultipleAccountsWithContextAsync( + [PublicKey.Parse(Address)], options); + + // Assert + result.Context!.Slot.Should().Be(77); + result.Value.Should().ContainSingle().Which.Should().BeNull(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getMultipleAccounts","params":[["11111111111111111111111111111111"],{"encoding":"base64","commitment":"confirmed","dataSlice":{"offset":1,"length":2},"minContextSlot":70}]}"""); + } + } + + [TestFixture] + public sealed class GetMultipleAccountsWithOptionsAsync + { + [Test] + public async Task FullConfig_ReturnsValuePath() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":77},"value":[null]}"""); + + // Act + var result = await client.GetMultipleAccountsWithOptionsAsync( + [PublicKey.Parse(Address)], + new RpcAccountInfoOptions { Encoding = RpcAccountEncoding.Base58, MinContextSlot = 70 }); + + // Assert + result.Should().ContainSingle().Which.Should().BeNull(); + handler.CapturedRequestBody.Should().Contain("\"encoding\":\"base58\""); + handler.CapturedRequestBody.Should().Contain("\"minContextSlot\":70"); + } + } + + [TestFixture] + public sealed class GetProgramAccountsWithContextAsync + { + [Test] + public async Task FullConfig_ParsesContextAndSendsExactJson() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":88},"value":[]}"""); + var options = new GetProgramAccountsOptions + { + Commitment = Commitment.Finalized, + Filters = [AccountFilter.DataSize(165)], + DataSlice = new DataSlice(0, 8), + MinContextSlot = 80, + SortResults = false + }; + + // Act + var result = await client.GetProgramAccountsWithContextAsync(PublicKey.Parse(Program), options); + + // Assert + result.Context!.Slot.Should().Be(88); + result.Value.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getProgramAccounts","params":["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",{"encoding":"base64","commitment":"finalized","minContextSlot":80,"dataSlice":{"offset":0,"length":8},"filters":[{"dataSize":165}],"withContext":true,"sortResults":false}]}"""); + } + } + + [TestFixture] + public sealed class GetProgramAccountsAsync + { + [Test] + public async Task WithContextTrue_ReturnsWrappedValueWithoutLosingCompatibility() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":88},"value":[]}"""); + + // Act + var result = await client.GetProgramAccountsAsync( + PublicKey.Parse(Program), new GetProgramAccountsOptions { WithContext = true }); + + // Assert + result.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Contain("\"withContext\":true"); + } + } + + [TestFixture] + public sealed class GetEpochInfoWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendMinContextSlot() + { + // Arrange + var (client, handler) = Make( + """{"absoluteSlot":42,"blockHeight":40,"epoch":1,"slotIndex":10,"slotsInEpoch":432000}"""); + + // Act + _ = await client.GetEpochInfoWithOptionsAsync(ContextOptions()); + + // Assert + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getEpochInfo","params":[{"commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class IsBlockhashValidWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendMinContextSlot() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":42},"value":true}"""); + + // Act + var result = await client.IsBlockhashValidWithOptionsAsync("hash", ContextOptions()); + + // Assert + result.Should().BeTrue(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"isBlockhashValid","params":["hash",{"commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class GetFeeForMessageWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendMinContextSlot() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":42},"value":5000}"""); + + // Act + var result = await client.GetFeeForMessageWithOptionsAsync([1, 2, 3], ContextOptions()); + + // Assert + result.Should().Be(5000); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getFeeForMessage","params":["AQID",{"commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class RequestAirdropWithOptionsAsync + { + [Test] + public async Task RecentBlockhash_SendsExactPinnedConfig() + { + // Arrange + var (client, handler) = Make("\"signature\""); + var options = new RequestAirdropOptions + { + RecentBlockhash = "recent", + Commitment = Commitment.Confirmed + }; + + // Act + var result = await client.RequestAirdropWithOptionsAsync(PublicKey.Parse(Address), 123, options); + + // Assert + result.Should().Be("signature"); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"requestAirdrop","params":["11111111111111111111111111111111",123,{"recentBlockhash":"recent","commitment":"confirmed"}]}"""); + } + } + + [TestFixture] + public sealed class GetTokenAccountsByOwnerWithFilterAsync + { + [Test] + public async Task MintUnionBranch_SendsExactConfig() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":9},"value":[]}"""); + + // Act + var result = await client.GetTokenAccountsByOwnerWithFilterAsync( + PublicKey.Parse(Address), + TokenAccountsFilter.ByMint(PublicKey.Parse(Address))); + + // Assert + result.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getTokenAccountsByOwner","params":["11111111111111111111111111111111",{"mint":"11111111111111111111111111111111"},{"encoding":"base64"}]}"""); + } + + [Test] + public async Task ProgramIdUnionBranch_SendsExactConfig() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":9},"value":[]}"""); + + // Act + var result = await client.GetTokenAccountsByOwnerWithFilterAsync( + PublicKey.Parse(Address), + TokenAccountsFilter.ByProgramId(PublicKey.Parse(Program)), + new GetAccountInfoOptions { DataSlice = new DataSlice(0, 0), MinContextSlot = 8 }); + + // Assert + result.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getTokenAccountsByOwner","params":["11111111111111111111111111111111",{"programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},{"encoding":"base64","dataSlice":{"offset":0,"length":0},"minContextSlot":8}]}"""); + } + } + + [TestFixture] + public sealed class GetTokenAccountsByDelegateWithFilterAsync + { + [Test] + public async Task ProgramIdUnionBranch_SendsExactConfig() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":9},"value":[]}"""); + + // Act + var result = await client.GetTokenAccountsByDelegateWithFilterAsync( + PublicKey.Parse(Address), + TokenAccountsFilter.ByProgramId(PublicKey.Parse(Program))); + + // Assert + result.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getTokenAccountsByDelegate","params":["11111111111111111111111111111111",{"programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},{"encoding":"base64"}]}"""); + } + } + + [TestFixture] + public sealed class GetVoteAccountsWithOptionsAsync + { + [Test] + public async Task FullConfig_SendsExactPinnedJson() + { + // Arrange + var (client, handler) = Make("""{"current":[],"delinquent":[]}"""); + var options = new GetVoteAccountsOptions + { + VotePublicKey = PublicKey.Parse(Address), + Commitment = Commitment.Finalized, + KeepUnstakedDelinquents = true, + DelinquentSlotDistance = 128 + }; + + // Act + var result = await client.GetVoteAccountsWithOptionsAsync(options); + + // Assert + result.Current.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getVoteAccounts","params":[{"votePubkey":"11111111111111111111111111111111","commitment":"finalized","keepUnstakedDelinquents":true,"delinquentSlotDistance":128}]}"""); + } + } + + [TestFixture] + public sealed class GetInflationRewardWithOptionsAsync + { + [Test] + public async Task EpochConfig_SendsMinContextSlot() + { + // Arrange + var (client, handler) = Make("[]"); + var options = new GetInflationRewardOptions + { + Epoch = 12, + Commitment = Commitment.Confirmed, + MinContextSlot = 99 + }; + + // Act + var result = await client.GetInflationRewardWithOptionsAsync([PublicKey.Parse(Address)], options); + + // Assert + result.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getInflationReward","params":[["11111111111111111111111111111111"],{"commitment":"confirmed","epoch":12,"minContextSlot":99}]}"""); + } + } + + [TestFixture] + public sealed class GetLeaderScheduleWithOptionsAsync + { + [Test] + public async Task IdentityFilter_SendsExactPinnedJson() + { + // Arrange + var (client, handler) = Make("{}"); + var options = new GetLeaderScheduleOptions + { + Slot = 123, + Identity = PublicKey.Parse(Address), + Commitment = Commitment.Finalized + }; + + // Act + var result = await client.GetLeaderScheduleWithOptionsAsync(options); + + // Assert + result.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getLeaderSchedule","params":[123,{"identity":"11111111111111111111111111111111","commitment":"finalized"}]}"""); + } + } + + [TestFixture] + public sealed class GetBlocksWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendMinContextSlot() + { + // Arrange + var (client, handler) = Make("[]"); + + // Act + var result = await client.GetBlocksWithOptionsAsync(10, 20, ContextOptions()); + + // Assert + result.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getBlocks","params":[10,20,{"commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class GetBlocksWithLimitWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendMinContextSlot() + { + // Arrange + var (client, handler) = Make("[]"); + + // Act + var result = await client.GetBlocksWithLimitWithOptionsAsync(10, 5, ContextOptions()); + + // Assert + result.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getBlocksWithLimit","params":[10,5,{"commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class GetLargestAccountsWithOptionsAsync + { + [Test] + public async Task SortResults_SendsExactPinnedJson() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":1},"value":[]}"""); + + // Act + var result = await client.GetLargestAccountsWithOptionsAsync(new GetLargestAccountsOptions + { + Commitment = Commitment.Processed, + Filter = LargestAccountsFilter.NonCirculating, + SortResults = false + }); + + // Assert + result.Should().BeEmpty(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getLargestAccounts","params":[{"commitment":"processed","filter":"nonCirculating","sortResults":false}]}"""); + } + + [Test] + public async Task NullEntry_ThrowsJsonException() + { + // Arrange + var (client, _) = Make("""{"context":{"slot":1},"value":[null]}"""); + + // Act + var act = async () => await client.GetLargestAccountsWithOptionsAsync(new GetLargestAccountsOptions()); + + // Assert + await act.Should().ThrowAsync(); + } + } + + [TestFixture] + public sealed class GetSupplyWithOptionsAsync + { + [Test] + public async Task IncludedList_ParsesPublicKeysAndSendsFalse() + { + // Arrange + var (client, handler) = Make( + """{"context":{"slot":1},"value":{"total":100,"circulating":90,"nonCirculating":10,"nonCirculatingAccounts":["11111111111111111111111111111111"]}}"""); + + // Act + var result = await client.GetSupplyWithOptionsAsync(new GetSupplyOptions + { + Commitment = Commitment.Finalized, + ExcludeNonCirculatingAccountsList = false + }); + + // Assert + result.NonCirculatingAccounts.Should().ContainSingle().Which.Should().Be(PublicKey.Parse(Address)); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getSupply","params":[{"commitment":"finalized","excludeNonCirculatingAccountsList":false}]}"""); + } + } + + [TestFixture] + public sealed class GetBlockWithOptionsAsync + { + [Test] + public async Task ExactConfig_ReturnsUnprojectedResponseKat() + { + // Arrange + var (client, handler) = Make( + """{"blockhash":"b","previousBlockhash":"p","parentSlot":4,"transactions":[{"opaque":true}],"rewards":[{"pubkey":"11111111111111111111111111111111"}]}"""); + var options = new GetBlockOptions + { + Encoding = RpcTransactionEncoding.Base64, + TransactionDetails = RpcTransactionDetails.Full, + Rewards = true, + Commitment = Commitment.Finalized, + MaxSupportedTransactionVersion = 1 + }; + + // Act + var result = await client.GetBlockWithOptionsAsync(5, options); + + // Assert + result!.Value.GetProperty("transactions")[0].GetProperty("opaque").GetBoolean().Should().BeTrue(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getBlock","params":[5,{"commitment":"finalized","maxSupportedTransactionVersion":1,"encoding":"base64","transactionDetails":"full","rewards":true}]}"""); + } + } + + [TestFixture] + public sealed class GetTransactionWithOptionsAsync + { + [Test] + public async Task ExactEncoding_ReturnsUnprojectedResponseKat() + { + // Arrange + var (client, handler) = Make("""{"slot":5,"transaction":{"message":{"opaque":7}}}"""); + var options = new GetTransactionOptions + { + Encoding = RpcTransactionEncoding.Json, + Commitment = Commitment.Confirmed, + MaxSupportedTransactionVersion = 1 + }; + + // Act + var result = await client.GetTransactionWithOptionsAsync("signature", options); + + // Assert + result!.Value.GetProperty("transaction").GetProperty("message").GetProperty("opaque").GetInt32().Should().Be(7); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getTransaction","params":["signature",{"commitment":"confirmed","maxSupportedTransactionVersion":1,"encoding":"json"}]}"""); + } + + [Test] + public async Task UnknownEncoding_ThrowsBeforeTransport() + { + // Arrange + var (client, _) = Make("null"); + var options = new GetTransactionOptions { Encoding = (RpcTransactionEncoding)int.MaxValue }; + + // Act + var act = () => client.GetTransactionWithOptionsAsync("signature", options); + + // Assert + await act.Should().ThrowAsync(); + } + } + + [TestFixture] + public sealed class GetSlotLeaderWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendMinContextSlot() + { + // Arrange + var (client, handler) = Make("\"11111111111111111111111111111111\""); + + // Act + var result = await client.GetSlotLeaderWithOptionsAsync(ContextOptions()); + + // Assert + result.Should().Be(PublicKey.Parse(Address)); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getSlotLeader","params":[{"commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class GetParsedAccountInfoWithContextAsync + { + [Test] + public async Task ContextOptions_SendExactPinnedConfigAndParseContext() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":43},"value":null}"""); + + // Act + var result = await client.GetParsedAccountInfoWithContextAsync( + PublicKey.Parse(Address), ContextOptions()); + + // Assert + result.Context!.Slot.Should().Be(43); + result.Value.Should().BeNull(); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getAccountInfo","params":["11111111111111111111111111111111",{"encoding":"jsonParsed","commitment":"finalized","minContextSlot":42}]}"""); + } + } + + [TestFixture] + public sealed class GetStakeMinimumDelegationWithOptionsAsync + { + [Test] + public async Task ContextOptions_SendMinContextSlot() + { + // Arrange + var (client, handler) = Make("""{"context":{"slot":42},"value":1}"""); + + // Act + var result = await client.GetStakeMinimumDelegationWithOptionsAsync(ContextOptions()); + + // Assert + result.Should().Be(1); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getStakeMinimumDelegation","params":[{"commitment":"finalized","minContextSlot":42}]}"""); + } + } +} diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientConfirmTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientConfirmTests.cs index 0dee34f..e9aaba1 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientConfirmTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientConfirmTests.cs @@ -2,6 +2,7 @@ using System.Text; using FluentAssertions; using NUnit.Framework; +using SolSharp.Core.Primitives; using SolSharp.Rpc.Protocol; namespace SolSharp.Rpc.Tests; @@ -9,7 +10,7 @@ namespace SolSharp.Rpc.Tests; public static class SolanaRpcClientConfirmTests { private const string ConfirmedStatus = - """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[{"slot":10,"confirmations":5,"err":null,"confirmationStatus":"confirmed"}]},"id":1}"""; + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[{"slot":10,"confirmations":5,"status":{"Ok":null},"err":null,"confirmationStatus":"confirmed"}]},"id":1}"""; private static (SolanaRpcClient Client, FakeHttpMessageHandler Handler) Make(string responseJson) { @@ -35,7 +36,7 @@ public async Task ParsesStatusesAndPreservesNulls() { // Arrange var (client, handler) = Make( - """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[{"slot":10,"confirmations":5,"err":null,"confirmationStatus":"confirmed"},null]},"id":1}"""); + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[{"slot":10,"confirmations":5,"status":{"Ok":null},"err":null,"confirmationStatus":"confirmed"},null]},"id":1}"""); // Act var statuses = await client.GetSignatureStatusesAsync(["Sig111", "Sig222"]); @@ -44,11 +45,33 @@ public async Task ParsesStatusesAndPreservesNulls() statuses.Should().HaveCount(2); statuses[0]!.Slot.Should().Be(10ul); statuses[0]!.Confirmations.Should().Be(5); + statuses[0]!.Status!.Value.GetProperty("Ok").ValueKind.Should().Be(System.Text.Json.JsonValueKind.Null); statuses[0]!.ConfirmationStatus.Should().Be("confirmed"); statuses[0]!.IsError.Should().BeFalse(); statuses[1].Should().BeNull(); handler.CapturedRequestBody.Should().Contain("\"getSignatureStatuses\""); } + + [TestCase("{}")] + [TestCase("{\"slot\":10,\"confirmations\":5,\"status\":null,\"err\":null,\"confirmationStatus\":\"confirmed\"}")] + [TestCase("{\"slot\":10,\"confirmations\":5,\"status\":{},\"err\":null,\"confirmationStatus\":\"confirmed\"}")] + [TestCase("{\"slot\":10,\"confirmations\":5,\"status\":{\"Ok\":null,\"extra\":1},\"err\":null,\"confirmationStatus\":\"confirmed\"}")] + [TestCase("{\"slot\":10,\"confirmations\":5,\"status\":{\"Ok\":1},\"err\":null,\"confirmationStatus\":\"confirmed\"}")] + [TestCase("{\"slot\":10,\"confirmations\":5,\"status\":{\"Err\":\"failure\"},\"err\":null,\"confirmationStatus\":\"confirmed\"}")] + [TestCase("{\"slot\":10,\"confirmations\":5,\"status\":{\"Ok\":null},\"err\":null,\"confirmationStatus\":\"future\"}")] + public async Task MalformedStatus_ThrowsJsonException(string status) + { + // Arrange + var response = """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[__STATUS__]},"id":1}""" + .Replace("__STATUS__", status, StringComparison.Ordinal); + var (client, _) = Make(response); + + // Act + var act = async () => await client.GetSignatureStatusesAsync(["Sig111"]); + + // Assert + await act.Should().ThrowAsync(); + } } [TestFixture] @@ -80,6 +103,91 @@ public async Task ThrowsTimeoutWhenUnconfirmed() // Assert await act.Should().ThrowAsync(); } + + [Test] + public async Task MalformedEmptyStatus_CannotBeMistakenForFinalized() + { + // Arrange + var (client, _) = Make( + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[{}]},"id":1}"""); + + // Act + var act = async () => await client.ConfirmTransactionAsync("Sig111", Commitment.Finalized); + + // Assert + await act.Should().ThrowAsync(); + } + + [Test] + public async Task Timeout_CancelsInFlightStatusRequest() + { + // Arrange + var handler = new BlockingHandler(); + var http = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }; + var client = new SolanaRpcClient(http); + + // Act + Func act = () => client.ConfirmTransactionAsync("Sig111", timeout: TimeSpan.FromMilliseconds(50)); + + // Assert + await act.Should().ThrowAsync(); + await handler.CancellationObserved.Task.WaitAsync(TimeSpan.FromSeconds(1)); + } + + [Test] + public async Task TimeoutBeyondTimerLimit_IsAccepted() + { + // Arrange + var (client, _) = Make(ConfirmedStatus); + + // Act + var status = await client.ConfirmTransactionAsync("Sig111", timeout: TimeSpan.FromDays(100)); + + // Assert + status.ConfirmationStatus.Should().Be("confirmed"); + } + + [Test] + public async Task MissingConfirmationStatus_UsesUpstreamConfirmationCountThreshold() + { + var handler = new SequenceHandler( + Json(StatusWithoutConfirmationStatus("1")), + Json(StatusWithoutConfirmationStatus("2"))); + var http = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }; + var client = new SolanaRpcClient(http); + + var status = await client.ConfirmTransactionAsync("Sig111", Commitment.Confirmed); + + status.Confirmations.Should().Be(2); + handler.CallCount.Should().Be(2, "one confirmation is still processed in the legacy response shape"); + } + + [Test] + public async Task MissingConfirmationStatus_NullConfirmationsMeansFinalized() + { + var (client, _) = Make(StatusWithoutConfirmationStatus("null")); + + var status = await client.ConfirmTransactionAsync("Sig111", Commitment.Finalized); + + status.Confirmations.Should().BeNull(); + } + + [Test] + public async Task MissingConfirmationStatus_ZeroConfirmationsMeansProcessed() + { + var (client, _) = Make(StatusWithoutConfirmationStatus("0")); + + var status = await client.ConfirmTransactionAsync("Sig111", Commitment.Processed); + + status.Confirmations.Should().Be(0); + } + + private static string StatusWithoutConfirmationStatus(string confirmations) => + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[{"slot":10,"confirmations":__CONFIRMATIONS__,"status":{"Ok":null},"err":null}]} ,"id":1}""" + .Replace("__CONFIRMATIONS__", confirmations); + + private static HttpResponseMessage Json(string body) + => new(HttpStatusCode.OK) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; } [TestFixture] @@ -105,7 +213,7 @@ public async Task ThrowsWhenTransactionFailsOnChain() // Arrange var client = Sequenced( """{"jsonrpc":"2.0","result":"SigFail","id":1}""", - """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[{"slot":10,"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]},"id":1}"""); + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":[{"slot":10,"confirmations":5,"status":{"Err":{"InstructionError":[0,"Custom"]}},"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]},"id":1}"""); // Act Func act = () => client.SendAndConfirmTransactionAsync([1, 2, 3]); @@ -114,4 +222,26 @@ public async Task ThrowsWhenTransactionFailsOnChain() await act.Should().ThrowAsync(); } } + + private sealed class BlockingHandler : HttpMessageHandler + { + public TaskCompletionSource CancellationObserved { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("The blocking handler unexpectedly resumed."); + } + catch (OperationCanceledException) + { + CancellationObserved.TrySetResult(); + throw; + } + } + } } diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientDirectCoverageTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientDirectCoverageTests.cs new file mode 100644 index 0000000..58c7567 --- /dev/null +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientDirectCoverageTests.cs @@ -0,0 +1,154 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Rpc.Tests; + +public static class SolanaRpcClientDirectCoverageTests +{ + private const string Address = "11111111111111111111111111111111"; + private const string Program = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + + private const string ProgramAccountJson = + """{"pubkey":"11111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":2039280,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":0,"space":165}}"""; + + private static (SolanaRpcClient Client, FakeHttpMessageHandler Handler) Make(string resultJson) + { + var handler = new FakeHttpMessageHandler( + $$"""{"jsonrpc":"2.0","result":{{resultJson}},"id":1}"""); + var http = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }; + return (new SolanaRpcClient(http), handler); + } + + [TestFixture] + public sealed class GetTokenAccountsByOwnerWithContextAsync + { + [Test] + public async Task ProgramFilterAndOptions_ParseContextAndUsePinnedWireShape() + { + // Arrange + var (client, handler) = Make( + $$"""{"context":{"slot":51,"apiVersion":"2.0.0"},"value":[{{ProgramAccountJson}}]}"""); + var options = new GetAccountInfoOptions + { + Commitment = Commitment.Finalized, + DataSlice = new DataSlice(4, 8), + MinContextSlot = 42 + }; + + // Act + var result = await client.GetTokenAccountsByOwnerWithContextAsync( + PublicKey.Parse(Address), + TokenAccountsFilter.ByProgramId(PublicKey.Parse(Program)), + options); + + // Assert + result.Context.Should().NotBeNull(); + result.Context!.Slot.Should().Be(51); + result.Context.ApiVersion.Should().Be("2.0.0"); + result.Value.Should().ContainSingle(); + result.Value![0].PublicKey.Should().Be(PublicKey.Parse(Address)); + result.Value[0].Account.Data.Should().Equal(1, 2, 3); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getTokenAccountsByOwner","params":["11111111111111111111111111111111",{"programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},{"encoding":"base64","commitment":"finalized","dataSlice":{"offset":4,"length":8},"minContextSlot":42}]}"""); + } + + [Test] + public async Task NullFilter_IsRejectedBeforeTransport() + { + // Arrange + var (client, handler) = Make("null"); + + // Act + Func act = async () => await client.GetTokenAccountsByOwnerWithContextAsync( + PublicKey.Parse(Address), null!); + + // Assert + await act.Should().ThrowAsync().WithParameterName("filter"); + handler.CapturedRequestBody.Should().BeNull(); + } + } + + [TestFixture] + public sealed class GetParsedAccountInfoWithOptionsAsync + { + [Test] + public async Task ContextOptions_ParseValueAndUsePinnedWireShape() + { + // Arrange + var (client, handler) = Make( + """{"context":{"slot":52},"value":{"lamports":2039280,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","executable":false,"rentEpoch":18446744073709551615,"space":165,"data":{"program":"spl-token","parsed":{"type":"account","info":{"mint":"11111111111111111111111111111111"}},"space":165}}}"""); + var options = new RpcContextOptions + { + Commitment = Commitment.Finalized, + MinContextSlot = 42 + }; + + // Act + var result = await client.GetParsedAccountInfoWithOptionsAsync(PublicKey.Parse(Address), options); + + // Assert + result.Should().NotBeNull(); + result!.Owner.Should().Be(PublicKey.Parse(Program)); + result.Program.Should().Be("spl-token"); + result.Parsed.Should().NotBeNull(); + result.Parsed!.Info.GetProperty("mint").GetString().Should().Be(Address); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getAccountInfo","params":["11111111111111111111111111111111",{"encoding":"jsonParsed","commitment":"finalized","minContextSlot":42}]}"""); + } + + [Test] + public async Task NullOptions_IsRejectedBeforeTransport() + { + // Arrange + var (client, handler) = Make("null"); + + // Act + Func act = async () => await client.GetParsedAccountInfoWithOptionsAsync( + PublicKey.Parse(Address), null!); + + // Assert + await act.Should().ThrowAsync().WithParameterName("options"); + handler.CapturedRequestBody.Should().BeNull(); + } + } + + [TestFixture] + public sealed class GetTokenAccountsByDelegateWithContextAsync + { + [Test] + public async Task MintFilterAndDefaults_ParseContextAndUsePinnedWireShape() + { + // Arrange + var (client, handler) = Make( + $$"""{"context":{"slot":53},"value":[{{ProgramAccountJson}}]}"""); + + // Act + var result = await client.GetTokenAccountsByDelegateWithContextAsync( + PublicKey.Parse(Address), + TokenAccountsFilter.ByMint(PublicKey.Parse(Address))); + + // Assert + result.Context!.Slot.Should().Be(53); + result.Value.Should().ContainSingle(); + result.Value![0].Account.Lamports.Should().Be(2039280); + handler.CapturedRequestBody.Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"getTokenAccountsByDelegate","params":["11111111111111111111111111111111",{"mint":"11111111111111111111111111111111"},{"encoding":"base64"}]}"""); + } + + [Test] + public async Task NullFilter_IsRejectedBeforeTransport() + { + // Arrange + var (client, handler) = Make("null"); + + // Act + Func act = async () => await client.GetTokenAccountsByDelegateWithContextAsync( + PublicKey.Parse(Address), null!); + + // Assert + await act.Should().ThrowAsync().WithParameterName("filter"); + handler.CapturedRequestBody.Should().BeNull(); + } + } +} diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientExtendedReadsTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientExtendedReadsTests.cs index 86c271b..54300b2 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientExtendedReadsTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientExtendedReadsTests.cs @@ -1,6 +1,8 @@ +using System.Text.Json; using FluentAssertions; using NUnit.Framework; using SolSharp.Core.Primitives; +using SolSharp.Rpc.Models; namespace SolSharp.Rpc.Tests; @@ -70,7 +72,7 @@ public async Task ParsesByIdentityAndRange() // Assert production.ByIdentity.Should().ContainKey(Node); - production.ByIdentity[Node].Should().Equal(86ul, 80ul); + production.ByIdentity[Node].Should().Be(new BlockProductionCounts(86ul, 80ul)); production.Range.FirstSlot.Should().Be(100ul); production.Range.LastSlot.Should().Be(200ul); handler.CapturedRequestBody.Should().Contain("\"getBlockProduction\""); @@ -90,6 +92,23 @@ public async Task SendsIdentityAndRange() handler.CapturedRequestBody.Should().Contain($"\"identity\":\"{Node}\""); handler.CapturedRequestBody.Should().Contain("\"range\":{\"firstSlot\":100,\"lastSlot\":200}"); } + + [TestCase("{}")] + [TestCase("{\"byIdentity\":{},\"range\":{}}")] + [TestCase("{\"byIdentity\":{\"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67\":[]},\"range\":{\"firstSlot\":100,\"lastSlot\":200}}")] + [TestCase("{\"byIdentity\":{\"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67\":[1]},\"range\":{\"firstSlot\":100,\"lastSlot\":200}}")] + [TestCase("{\"byIdentity\":{\"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67\":[1,2,3]},\"range\":{\"firstSlot\":100,\"lastSlot\":200}}")] + public async Task MalformedResponse_ThrowsJsonException(string production) + { + // Arrange + var (client, _) = Make(ContextResult(production)); + + // Act + var act = async () => await client.GetBlockProductionAsync(); + + // Assert + await act.Should().ThrowAsync(); + } } [TestFixture] @@ -235,6 +254,19 @@ public async Task UnwrapsIdentityEnvelope() (await client.GetIdentityAsync()).Should().Be(PublicKey.Parse(Node)); handler.CapturedRequestBody.Should().Contain("\"getIdentity\""); } + + [Test] + public async Task MissingIdentity_ThrowsJsonException() + { + // Arrange + var (client, _) = Make(Result("{}")); + + // Act + var act = async () => await client.GetIdentityAsync(); + + // Assert + await act.Should().ThrowAsync(); + } } [TestFixture] @@ -330,6 +362,19 @@ public async Task SendsCirculatingFilter() // Assert handler.CapturedRequestBody.Should().Contain("\"filter\":\"circulating\""); } + + [Test] + public async Task NullEntry_ThrowsJsonException() + { + // Arrange + var (client, _) = Make(ContextResult("[null]")); + + // Act + var act = async () => await client.GetLargestAccountsAsync(); + + // Assert + await act.Should().ThrowAsync(); + } } [TestFixture] @@ -400,6 +445,19 @@ public async Task OmitsLimitWhenNull() // Assert handler.CapturedRequestBody.Should().Contain("\"params\":[]"); } + + [Test] + public async Task NullEntry_ThrowsJsonException() + { + // Arrange + var (client, _) = Make(Result("[null]")); + + // Act + var act = async () => await client.GetRecentPerformanceSamplesAsync(); + + // Assert + await act.Should().ThrowAsync(); + } } [TestFixture] diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientGetTransactionTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientGetTransactionTests.cs index dbcff06..2a36a64 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientGetTransactionTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientGetTransactionTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using NUnit.Framework; using SolSharp.Core.Primitives; +using SolSharp.Rpc.Models; namespace SolSharp.Rpc.Tests; @@ -22,15 +23,17 @@ public async Task ParsesSlotBlockTimeAndMeta() { // Arrange var (client, handler) = Make( - """{"jsonrpc":"2.0","result":{"slot":100,"blockTime":1700000000,"transaction":["AQID","base64"],"meta":{"err":null,"fee":5000,"preBalances":[100,200],"postBalances":[95,205],"logMessages":["Program log: ok"],"computeUnitsConsumed":1234},"version":0},"id":1}"""); + """{"jsonrpc":"2.0","result":{"slot":100,"blockTime":1700000000,"transaction":["AQID","base64"],"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[100,200],"postBalances":[95,205],"logMessages":["Program log: ok"],"computeUnitsConsumed":1234},"version":0},"id":1}"""); // Act - var transaction = await client.GetTransactionAsync("Sig1111"); + // Keep the default literal in the legacy third-argument position as a source-compatibility KAT. + var transaction = await client.GetTransactionAsync("Sig1111", Commitment.Confirmed, default); // Assert transaction.Should().NotBeNull(); transaction!.Slot.Should().Be(100); transaction.BlockTime.Should().Be(1700000000); + transaction.Version.Should().Be(RpcTransactionVersion.FromNumber(0)); transaction.Meta.Should().NotBeNull(); transaction.Meta!.IsError.Should().BeFalse(); transaction.Meta.Fee.Should().Be(5000); @@ -44,7 +47,7 @@ public async Task ParsesSlotBlockTimeAndMeta() handler.CapturedRequestBody.Should().Contain("\"getTransaction\""); handler.CapturedRequestBody.Should().Contain("Sig1111"); - handler.CapturedRequestBody.Should().Contain("maxSupportedTransactionVersion"); + handler.CapturedRequestBody.Should().Contain("\"maxSupportedTransactionVersion\":0"); } [Test] @@ -60,12 +63,29 @@ public async Task ReturnsNullWhenNotFound() transaction.Should().BeNull(); } + [Test] + public async Task ExplicitVersionOptIn_SendsVersionOneAndPreservesOpaqueBytes() + { + // Arrange + var (client, handler) = Make( + """{"jsonrpc":"2.0","result":{"slot":101,"blockTime":null,"transaction":["gQECAw==","base64"],"meta":null,"version":1},"id":1}"""); + + // Act + var transaction = await client.GetTransactionWithMaxVersionAsync( + "SigV1", maxSupportedTransactionVersion: 1); + + // Assert + transaction!.Version.Should().Be(RpcTransactionVersion.FromNumber(1)); + transaction.Transaction.Should().Equal(129, 1, 2, 3); + handler.CapturedRequestBody.Should().Contain("\"maxSupportedTransactionVersion\":1"); + } + [Test] public async Task SurfacesErrAsIsError() { // Arrange var (client, _) = Make( - """{"jsonrpc":"2.0","result":{"slot":7,"blockTime":null,"meta":{"err":{"InstructionError":[0,"Custom"]},"fee":5000}},"id":1}"""); + """{"jsonrpc":"2.0","result":{"slot":7,"blockTime":null,"transaction":["","base64"],"meta":{"err":{"InstructionError":[0,"Custom"]},"status":{"Err":{"InstructionError":[0,"Custom"]}},"fee":5000,"preBalances":[],"postBalances":[]}},"id":1}"""); // Act var transaction = await client.GetTransactionAsync("Sig1111"); @@ -82,7 +102,7 @@ public async Task ParsesTokenBalancesInnerInstructionsAndLoadedAddresses() { // Arrange var (client, _) = Make( - """{"jsonrpc":"2.0","result":{"slot":100,"transaction":["AQID","base64"],"meta":{"err":null,"fee":5000,"preTokenBalances":[{"accountIndex":1,"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","owner":"11111111111111111111111111111111","uiTokenAmount":{"amount":"1000000","decimals":6,"uiAmount":1.0,"uiAmountString":"1"}}],"postTokenBalances":[{"accountIndex":1,"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","owner":"11111111111111111111111111111111","uiTokenAmount":{"amount":"2000000","decimals":6,"uiAmount":2.0,"uiAmountString":"2"}}],"innerInstructions":[{"index":0,"instructions":[{"programIdIndex":5,"accounts":[1,2,3],"data":"3Bxs","stackHeight":2}]}],"loadedAddresses":{"writable":["So11111111111111111111111111111111111111112"],"readonly":["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"]}}},"id":1}"""); + """{"jsonrpc":"2.0","result":{"slot":100,"blockTime":null,"transaction":["AQID","base64"],"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[],"postBalances":[],"preTokenBalances":[{"accountIndex":1,"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","owner":"11111111111111111111111111111111","uiTokenAmount":{"amount":"1000000","decimals":6,"uiAmount":1.0,"uiAmountString":"1"}}],"postTokenBalances":[{"accountIndex":1,"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","owner":"11111111111111111111111111111111","uiTokenAmount":{"amount":"2000000","decimals":6,"uiAmount":2.0,"uiAmountString":"2"}}],"innerInstructions":[{"index":0,"instructions":[{"programIdIndex":5,"accounts":[1,2,3],"data":"3Bxs","stackHeight":2}]}],"loadedAddresses":{"writable":["So11111111111111111111111111111111111111112"],"readonly":["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"]}}},"id":1}"""); // Act var meta = (await client.GetTransactionAsync("Sig1111"))!.Meta!; @@ -117,7 +137,7 @@ public async Task UnexpectedTransactionEncoding_ThrowsJsonException() { // Arrange var (client, _) = Make( - "{\"jsonrpc\":\"2.0\",\"result\":{\"slot\":100,\"transaction\":[\"AQID\",\"base58\"],\"meta\":null},\"id\":1}"); + "{\"jsonrpc\":\"2.0\",\"result\":{\"slot\":100,\"blockTime\":null,\"transaction\":[\"AQID\",\"base58\"],\"meta\":null},\"id\":1}"); // Act var act = async () => await client.GetTransactionAsync("Sig1111"); @@ -132,7 +152,7 @@ public async Task IncompleteTransactionTuple_ThrowsJsonException() { // Arrange var (client, _) = Make( - "{\"jsonrpc\":\"2.0\",\"result\":{\"slot\":100,\"transaction\":[\"AQID\"],\"meta\":null},\"id\":1}"); + "{\"jsonrpc\":\"2.0\",\"result\":{\"slot\":100,\"blockTime\":null,\"transaction\":[\"AQID\"],\"meta\":null},\"id\":1}"); // Act var act = async () => await client.GetTransactionAsync("Sig1111"); @@ -141,5 +161,30 @@ public async Task IncompleteTransactionTuple_ThrowsJsonException() await act.Should().ThrowAsync() .WithMessage("*two-element array*"); } + + [Test] + public async Task ParsesCurrentMetadataAndLegacyVersion() + { + // Arrange + var (client, _) = Make( + """{"jsonrpc":"2.0","result":{"slot":100,"blockTime":1700000000,"transactionIndex":4,"transaction":["AQID","base64"],"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[],"postBalances":[],"costUnits":77,"returnData":{"programId":"11111111111111111111111111111111","data":["BAU=","base64"]},"rewards":[{"pubkey":"11111111111111111111111111111111","lamports":-5,"postBalance":95,"rewardType":"Fee","commission":7,"commissionBps":725}]},"version":"legacy"},"id":1}"""); + + // Act + var transaction = await client.GetTransactionAsync("Sig1111"); + + // Assert + transaction!.TransactionIndex.Should().Be(4); + transaction.Version.Should().Be(RpcTransactionVersion.Legacy); + transaction.Meta!.Status.GetProperty("Ok").ValueKind.Should().Be(JsonValueKind.Null); + transaction.Meta.CostUnits.Should().Be(77); + transaction.Meta.ReturnData!.Data.Should().Equal(4, 5); + var reward = transaction.Meta.Rewards.Should().ContainSingle().Subject; + reward.PublicKey.Should().Be(PublicKey.Parse("11111111111111111111111111111111")); + reward.Lamports.Should().Be(-5); + reward.PostBalance.Should().Be(95); + reward.RewardType.Should().Be("Fee"); + reward.Commission.Should().Be(7); + reward.CommissionBps.Should().Be(725); + } } } diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientLookupTableTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientLookupTableTests.cs index a2e455d..82a7fee 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientLookupTableTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientLookupTableTests.cs @@ -1,6 +1,9 @@ +using System.Buffers.Binary; using FluentAssertions; using NUnit.Framework; +using SolSharp.Core.Constants; using SolSharp.Core.Primitives; +using SolSharp.Rpc.Models; namespace SolSharp.Rpc.Tests; @@ -20,9 +23,28 @@ private static (SolanaRpcClient Client, FakeHttpMessageHandler Handler) Make(str return (new SolanaRpcClient(http), handler); } - private static string AccountEnvelope(string dataBase64) => - """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"data":["__DATA__","base64"],"executable":false,"lamports":1,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":120}},"id":1}""" - .Replace("__DATA__", dataBase64); + private static string AccountEnvelope( + string dataBase64, + string owner = SolanaProgramIds.AddressLookupTableProgram, + ulong contextSlot = 124) => + """{"jsonrpc":"2.0","result":{"context":{"slot":__SLOT__},"value":{"data":["__DATA__","base64"],"executable":false,"lamports":1,"owner":"__OWNER__","rentEpoch":0,"space":120}},"id":1}""" + .Replace("__DATA__", dataBase64) + .Replace("__OWNER__", owner) + .Replace("__SLOT__", contextSlot.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + private static string WithMetadata( + ulong deactivationSlot = ulong.MaxValue, + ulong lastExtendedSlot = 123, + byte lastExtendedSlotStartIndex = 0, + ushort padding = 0) + { + var data = Convert.FromBase64String(TableDataBase64); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(4), deactivationSlot); + BinaryPrimitives.WriteUInt64LittleEndian(data.AsSpan(12), lastExtendedSlot); + data[20] = lastExtendedSlotStartIndex; + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(54), padding); + return Convert.ToBase64String(data); + } [TestFixture] public sealed class GetAddressLookupTableAsync @@ -39,10 +61,16 @@ public async Task DecodesActiveTable() // Assert table.Should().NotBeNull(); table!.IsActive.Should().BeTrue(); + table.IsUsable.Should().BeTrue(); + table.Lifecycle.Should().Be(AddressLookupTableLifecycle.Activated); table.DeactivationSlot.Should().Be(ulong.MaxValue); table.LastExtendedSlot.Should().Be(123); + table.LastExtendedSlotStartIndex.Should().Be(0); + table.ContextSlot.Should().Be(124); + table.Padding.Should().Be(0); table.Authority.Should().Be(PublicKey.Parse("cGfHiC6Kgg3FpFZvgwGcswsCRtp4aBP2fzuXRQPizuN")); table.Addresses.Should().HaveCount(2); + table.StoredAddresses.Should().HaveCount(2); table.Addresses[0].Should().Be(PublicKey.Parse("8qbHbw2BbbTHBW1sbeqakYXVKRQM8Ne7pLK7m6CVfeR")); table.Addresses[1].Should().Be(PublicKey.Parse("CktRuQ2mttgRGkXJtyksdKHjUdc2C4TgDzyB98oEzy8")); @@ -50,6 +78,88 @@ public async Task DecodesActiveTable() handler.CapturedRequestBody.Should().Contain(TableAddress); } + [Test] + public async Task SameSlotExtension_ExposesOnlyPreviouslyActivePrefix() + { + // Arrange: index 1 is the first address appended at slot 123. + var (client, _) = Make(AccountEnvelope( + WithMetadata(lastExtendedSlotStartIndex: 1), + contextSlot: 123)); + + // Act + var table = await client.GetAddressLookupTableAsync(PublicKey.Parse(TableAddress)); + + // Assert + table.Should().NotBeNull(); + table!.LastExtendedSlotStartIndex.Should().Be(1); + table.ContextSlot.Should().Be(123); + table.StoredAddresses.Should().HaveCount(2); + table.Addresses.Should().ContainSingle() + .Which.Should().Be(PublicKey.Parse("8qbHbw2BbbTHBW1sbeqakYXVKRQM8Ne7pLK7m6CVfeR")); + } + + [Test] + public async Task DeactivationStart_RemainsUsableDuringCooldown() + { + // Arrange + const ulong deactivationSlot = 200; + var (client, _) = Make(AccountEnvelope( + WithMetadata(deactivationSlot: deactivationSlot, padding: 0x1234), + contextSlot: deactivationSlot)); + + // Act + var table = await client.GetAddressLookupTableAsync(PublicKey.Parse(TableAddress)); + + // Assert + table.Should().NotBeNull(); + table!.IsActive.Should().BeFalse("deactivation has begun"); + table.Lifecycle.Should().Be(AddressLookupTableLifecycle.Deactivating); + table.IsUsable.Should().BeTrue("Agave permits lookups during the SlotHashes cooldown"); + table.DeactivationSlot.Should().Be(deactivationSlot); + table.Padding.Should().Be(0x1234); + table.Addresses.Should().HaveCount(2); + } + + [Test] + public async Task LastGuaranteedCooldownSlot_RemainsUsable() + { + // Arrange: with no skipped blocks, the deactivation slot is still the oldest of the 512 + // SlotHashes entries at D + 512 (position 511), so Agave still permits lookups. + var (client, _) = Make(AccountEnvelope( + WithMetadata(deactivationSlot: 100), + contextSlot: 612)); + + // Act + var table = await client.GetAddressLookupTableAsync(PublicKey.Parse(TableAddress)); + + // Assert + table.Should().NotBeNull(); + table!.IsActive.Should().BeFalse(); + table.Lifecycle.Should().Be(AddressLookupTableLifecycle.Deactivating); + table.IsUsable.Should().BeTrue(); + table.DeactivationSlot.Should().Be(100); + } + + [Test] + public async Task BeyondGuaranteedCooldown_DoesNotGuessWithoutSlotHashes() + { + // Arrange: after D + 512, skipped blocks may still retain the deactivation slot, so the + // account response alone cannot prove whether the table is cooling down or deactivated. + var (client, _) = Make(AccountEnvelope( + WithMetadata(deactivationSlot: 100), + contextSlot: 613)); + + // Act + var table = await client.GetAddressLookupTableAsync(PublicKey.Parse(TableAddress)); + + // Assert + table.Should().NotBeNull(); + table!.IsActive.Should().BeFalse(); + table.Lifecycle.Should().Be(AddressLookupTableLifecycle.DeactivationStatusUnknown); + table.IsUsable.Should().BeNull(); + table.DeactivationSlot.Should().Be(100); + } + [Test] public async Task ReturnsNullWhenAccountMissing() { @@ -76,5 +186,49 @@ public async Task ReturnsNullWhenDataIsNotALookupTable() // Assert table.Should().BeNull(); } + + [Test] + public async Task WrongOwner_ReturnsNull() + { + // Arrange + var (client, _) = Make(AccountEnvelope(TableDataBase64, SolanaProgramIds.SystemProgram)); + + // Act + var table = await client.GetAddressLookupTableAsync(PublicKey.Parse(TableAddress)); + + // Assert + table.Should().BeNull(); + } + } + + [TestFixture] + public sealed class Decode + { + [Test] + public void InvalidAuthorityOptionOrUnalignedTail_ReturnsNull() + { + // Arrange + var invalidOption = Convert.FromBase64String(TableDataBase64); + invalidOption[21] = 2; + byte[] unalignedTail = [.. Convert.FromBase64String(TableDataBase64), 0]; + + // Act & Assert + AddressLookupTable.Decode(invalidOption).Should().BeNull(); + AddressLookupTable.Decode(unalignedTail).Should().BeNull(); + } + + [Test] + public void InvalidStartIndexOrAddressCount_ReturnsNull() + { + // Arrange + var invalidStartIndex = Convert.FromBase64String(WithMetadata(lastExtendedSlotStartIndex: 3)); + var tooManyAddresses = new byte[56 + (257 * PublicKey.Length)]; + BinaryPrimitives.WriteUInt32LittleEndian(tooManyAddresses, 1); + BinaryPrimitives.WriteUInt64LittleEndian(tooManyAddresses.AsSpan(4), ulong.MaxValue); + + // Act & Assert + AddressLookupTable.Decode(invalidStartIndex, 123).Should().BeNull(); + AddressLookupTable.Decode(tooManyAddresses).Should().BeNull(); + } } } diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientNonceTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientNonceTests.cs index ff2d317..f94330b 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientNonceTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientNonceTests.cs @@ -1,6 +1,7 @@ using System.Buffers.Binary; using FluentAssertions; using NUnit.Framework; +using SolSharp.Core.Constants; using SolSharp.Core.Primitives; using SolSharp.Rpc.Models; @@ -59,6 +60,17 @@ public void UninitializedState_ReturnsNull() [Test] public void TooShort_ReturnsNull() => NonceAccount.Decode(NonceData().AsSpan(0, NonceAccount.Length - 1)).Should().BeNull(); + + [Test] + public void TrailingDataOrUnknownVersion_ReturnsNull() + { + byte[] oversized = [.. NonceData(), 0]; + var unknownVersion = NonceData(); + BinaryPrimitives.WriteUInt32LittleEndian(unknownVersion, 2); + + NonceAccount.Decode(oversized).Should().BeNull(); + NonceAccount.Decode(unknownVersion).Should().BeNull(); + } } [TestFixture] @@ -96,5 +108,18 @@ public async Task MissingAccount_ReturnsNull() // Act & Assert (await client.GetNonceAccountAsync(Pk(2))).Should().BeNull(); } + + [Test] + public async Task WrongOwner_ReturnsNull() + { + var envelope = + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"data":["__DATA__","base64"],"executable":false,"lamports":1,"owner":"__OWNER__","rentEpoch":0,"space":80}},"id":1}""" + .Replace("__DATA__", Convert.ToBase64String(NonceData())) + .Replace("__OWNER__", SolanaProgramIds.TokenProgram); + var http = new HttpClient(new FakeHttpMessageHandler(envelope)) { BaseAddress = new Uri("http://localhost") }; + var client = new SolanaRpcClient(http); + + (await client.GetNonceAccountAsync(Pk(2))).Should().BeNull(); + } } } diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientParsedAccountTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientParsedAccountTests.cs index ed804b0..bd9c1a5 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientParsedAccountTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientParsedAccountTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using FluentAssertions; using NUnit.Framework; using SolSharp.Core.Primitives; @@ -69,6 +70,84 @@ public async Task ReturnsNullWhenNotFound() // Act & Assert (await client.GetParsedAccountInfoAsync(PublicKey.Parse(Owner))).Should().BeNull(); } + + [TestCase("[\"AQID\"]")] + [TestCase("[\"AQID\",\"base58\"]")] + [TestCase("[\"AQID\",\"base64\",\"extra\"]")] + public async Task RawFallbackRequiresCanonicalBase64Tuple(string data) + { + var response = + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"lamports":1,"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":0,"data":__DATA__}},"id":1}""" + .Replace("__DATA__", data); + var (client, _) = Make(response); + + Func act = async () => await client.GetParsedAccountInfoAsync(PublicKey.Parse(Owner)); + + await act.Should().ThrowAsync(); + } + + [TestCase("\"oops\"")] + [TestCase("true")] + [TestCase("{}")] + [TestCase("-1")] + [TestCase("18446744073709551616")] + public async Task RawFallbackPresentSpaceOutsideOptionalU64_ThrowsJsonException(string space) + { + // Arrange + var response = + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"lamports":1,"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":0,"space":__SPACE__,"data":["AQID","base64"]}},"id":1}""" + .Replace("__SPACE__", space, StringComparison.Ordinal); + var (client, _) = Make(response); + + // Act + var act = async () => await client.GetParsedAccountInfoAsync(PublicKey.Parse(Owner)); + + // Assert + await act.Should().ThrowAsync(); + } + + [TestCase("{}")] + [TestCase("{\"program\":null,\"parsed\":{},\"space\":165}")] + [TestCase("{\"program\":\"spl-token\",\"space\":165}")] + [TestCase("{\"program\":\"spl-token\",\"parsed\":{},\"space\":null}")] + [TestCase("{\"program\":\"spl-token\",\"parsed\":{},\"space\":-1}")] + public async Task ParsedBranchRequiresCanonicalMembers(string data) + { + // Arrange + var response = + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"lamports":1,"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":0,"data":__DATA__}},"id":1}""" + .Replace("__DATA__", data, StringComparison.Ordinal); + var (client, _) = Make(response); + + // Act + var act = async () => await client.GetParsedAccountInfoAsync(PublicKey.Parse(Owner)); + + // Assert + await act.Should().ThrowAsync(); + } + + [TestCase("null")] + [TestCase("\"memo\"")] + [TestCase("[1,2]")] + public async Task ParsedBranchPreservesAnyPresentJsonValue(string parsedValue) + { + // Arrange + var response = + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"lamports":1,"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":0,"data":{"program":"custom","parsed":__PARSED__,"space":3}}},"id":1}""" + .Replace("__PARSED__", parsedValue, StringComparison.Ordinal); + var (client, _) = Make(response); + + // Act + var account = await client.GetParsedAccountInfoAsync(PublicKey.Parse(Owner)); + + // Assert + account.Should().NotBeNull(); + account!.Program.Should().Be("custom"); + if (parsedValue == "null") + account.Parsed.Should().BeNull(); + else + account.Parsed!.Info.GetRawText().Should().Be(parsedValue); + } } private const string TokenAccountJson = diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientParsedTransactionTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientParsedTransactionTests.cs index 6978ecd..c783f8f 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientParsedTransactionTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientParsedTransactionTests.cs @@ -1,6 +1,7 @@ using FluentAssertions; using NUnit.Framework; using SolSharp.Core.Primitives; +using SolSharp.Rpc.Models; namespace SolSharp.Rpc.Tests; @@ -43,6 +44,7 @@ public async Task ParsesSystemTransfer() tx.Should().NotBeNull(); tx!.Slot.Should().Be(250000000); tx.BlockTime.Should().Be(1700000000); + tx.Version.Should().Be(RpcTransactionVersion.Legacy); tx.Signatures.Should().ContainSingle().Which.Should().Be("sig1aaaa"); tx.Message.RecentBlockhash.Should().Be("RBh1transfer1111111111111111111111111111111"); @@ -118,12 +120,37 @@ public async Task DecodesVersionedTransactionWithLoadedAddresses() // Assert tx.Should().NotBeNull(); - tx!.Meta!.LoadedAddresses.Should().NotBeNull(); + tx!.Version.Should().Be(RpcTransactionVersion.FromNumber(0)); + tx.Meta!.LoadedAddresses.Should().NotBeNull(); tx.Meta.LoadedAddresses!.Writable.Should().ContainSingle().Which.Should().Be(Key(V0Writable)); tx.Meta.LoadedAddresses.Readonly.Should().ContainSingle().Which.Should().Be(Key(V0Readonly)); tx.Message.AccountKeys.Should().Contain(account => account.Source == "lookupTable"); } + [Test] + public async Task ParsesCurrentMetadataAndMessageLookupReferences() + { + // Arrange + var (client, _) = Make( + """{"jsonrpc":"2.0","result":{"slot":8,"blockTime":9,"transactionIndex":3,"transaction":{"signatures":["sig-current"],"message":{"accountKeys":[],"instructions":[],"recentBlockhash":"CktRuQ2mttgRGkXJtyksdKHjUdc2C4TgDzyB98oEzy8","addressTableLookups":[{"accountKey":"11111111111111111111111111111111","writableIndexes":[1,2],"readonlyIndexes":[3]}]}},"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[],"postBalances":[],"computeUnitsConsumed":50,"costUnits":60,"returnData":{"programId":"11111111111111111111111111111111","data":["AQID","base64"]},"rewards":[{"pubkey":"11111111111111111111111111111111","lamports":-1,"postBalance":99,"rewardType":"Rent","commission":null}]},"version":0},"id":1}"""); + + // Act + var transaction = await client.GetParsedTransactionAsync("sig-current"); + + // Assert + transaction!.TransactionIndex.Should().Be(3); + transaction.Version.Should().Be(RpcTransactionVersion.FromNumber(0)); + var lookup = transaction.Message.AddressTableLookups.Should().ContainSingle().Subject; + lookup.AccountKey.Should().Be(Key(SystemId)); + lookup.WritableIndexes.Should().Equal(1, 2); + lookup.ReadonlyIndexes.Should().Equal(3); + transaction.Meta!.ComputeUnitsConsumed.Should().Be(50); + transaction.Meta.Status.GetProperty("Ok").ValueKind.Should().Be(System.Text.Json.JsonValueKind.Null); + transaction.Meta.CostUnits.Should().Be(60); + transaction.Meta.ReturnData!.Data.Should().Equal(1, 2, 3); + transaction.Meta.Rewards.Should().ContainSingle().Which.Lamports.Should().Be(-1); + } + [Test] public async Task ReturnsNullWhenNotFound() { @@ -145,13 +172,27 @@ public async Task ToleratesMissingOptionalFields() // Assert tx.Should().NotBeNull(); - tx!.Slot.Should().BeNull(); + tx!.Slot.Should().Be(0); tx.BlockTime.Should().BeNull(); tx.Meta.Should().BeNull(); tx.Message.Instructions.Should().BeEmpty(); tx.Message.AccountKeys.Should().ContainSingle().Which.Source.Should().BeNull(); } + [Test] + public async Task MissingTopLevelSlotAndBlockTime_ThrowsJsonException() + { + // Arrange + var (client, _) = Make( + """{"jsonrpc":"2.0","result":{"transaction":{"signatures":[],"message":{"accountKeys":[],"instructions":[],"recentBlockhash":""}},"meta":null},"id":1}"""); + + // Act + var act = async () => await client.GetParsedTransactionAsync("sig-missing-context"); + + // Assert + await act.Should().ThrowAsync().WithMessage("*slot*block-time*"); + } + [Test] public async Task ParsesMemoInstructionWhoseParsedIsAString() { @@ -205,6 +246,34 @@ public async Task ParsesMemoInvokedAsInnerInstruction() } } + [TestFixture] + public sealed class GetParsedTransactionWithMaxVersionAsync + { + [Test] + public async Task ParsesV1TransactionConfigAndSendsExplicitOptIn() + { + // Arrange + var (client, handler) = Make( + """{"jsonrpc":"2.0","result":{"slot":10,"blockTime":null,"transaction":{"signatures":["sig-v1"],"message":{"accountKeys":[],"instructions":[],"recentBlockhash":"CktRuQ2mttgRGkXJtyksdKHjUdc2C4TgDzyB98oEzy8","transactionConfig":{"priorityFee":9000,"computeUnitLimit":200000,"loadedAccountsDataSizeLimit":65536,"heapSize":32768}}},"meta":null,"version":1},"id":1}"""); + + // Act + var transaction = await client.GetParsedTransactionWithMaxVersionAsync( + "sig-v1", maxSupportedTransactionVersion: 1); + + // Assert + transaction!.Version.Should().Be(RpcTransactionVersion.FromNumber(1)); + transaction.Message.AddressTableLookups.Should().BeNull(); + transaction.Message.TransactionConfig.Should().NotBeNull(); + var config = transaction.Message.TransactionConfig!; + config.PriorityFee.Should().Be(9000); + config.ComputeUnitLimit.Should().Be(200000); + config.LoadedAccountsDataSizeLimit.Should().Be(65536); + config.HeapSize.Should().Be(32768); + handler.CapturedRequestBody.Should().Contain("\"encoding\":\"jsonParsed\""); + handler.CapturedRequestBody.Should().Contain("\"maxSupportedTransactionVersion\":1"); + } + } + [TestFixture] public sealed class GetParsedBlockAsync { @@ -223,13 +292,16 @@ public async Task ParsesTransactionsAndFillsSlotAndBlockTime() block.ParentSlot.Should().Be(249999999); block.BlockHeight.Should().Be(123456); block.BlockTime.Should().Be(1700000005); + block.NumRewardPartitions.Should().Be(4); block.Transactions.Should().HaveCount(2); var first = block.Transactions[0]; first.Slot.Should().Be(250000000); // patched from the requested slot first.BlockTime.Should().Be(1700000005); // patched from the block + first.TransactionIndex.Should().Be(0); // derived from ledger order first.Message.Instructions[0].Parsed!.Type.Should().Be("transfer"); + block.Transactions[1].TransactionIndex.Should().Be(1); block.Transactions[1].Meta.Should().BeNull(); handler.CapturedRequestBody.Should().Contain("getBlock"); @@ -238,29 +310,49 @@ public async Task ParsesTransactionsAndFillsSlotAndBlockTime() } } + [TestFixture] + public sealed class GetParsedBlockWithMaxVersionAsync + { + [Test] + public async Task ExplicitVersionOptIn_SendsVersionOne() + { + // Arrange + var (client, handler) = Make(BlockJson); + + // Act + var block = await client.GetParsedBlockWithMaxVersionAsync( + 250000000, maxSupportedTransactionVersion: 1); + + // Assert + block.Should().NotBeNull(); + handler.CapturedRequestBody.Should().Contain("\"encoding\":\"jsonParsed\""); + handler.CapturedRequestBody.Should().Contain("\"maxSupportedTransactionVersion\":1"); + } + } + private const string Transfer = - """{"jsonrpc":"2.0","result":{"slot":250000000,"blockTime":1700000000,"transaction":{"signatures":["sig1aaaa"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":"transaction"},{"pubkey":"9jLkNAaW9E47LQMHvjohy2uAAyr1331bAxgJKFRU7wF6","signer":false,"writable":true,"source":"transaction"},{"pubkey":"11111111111111111111111111111111","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"program":"system","programId":"11111111111111111111111111111111","parsed":{"type":"transfer","info":{"source":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","destination":"9jLkNAaW9E47LQMHvjohy2uAAyr1331bAxgJKFRU7wF6","lamports":1000000}},"stackHeight":null}],"recentBlockhash":"RBh1transfer1111111111111111111111111111111"}},"meta":{"err":null,"fee":5000,"preBalances":[100000000,0,1],"postBalances":[98995000,1000000,1],"innerInstructions":[],"logMessages":["Program 11111111111111111111111111111111 invoke [1]","Program 11111111111111111111111111111111 success"],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":[],"readonly":[]}},"version":"legacy"},"id":1}"""; + """{"jsonrpc":"2.0","result":{"slot":250000000,"blockTime":1700000000,"transaction":{"signatures":["sig1aaaa"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":"transaction"},{"pubkey":"9jLkNAaW9E47LQMHvjohy2uAAyr1331bAxgJKFRU7wF6","signer":false,"writable":true,"source":"transaction"},{"pubkey":"11111111111111111111111111111111","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"program":"system","programId":"11111111111111111111111111111111","parsed":{"type":"transfer","info":{"source":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","destination":"9jLkNAaW9E47LQMHvjohy2uAAyr1331bAxgJKFRU7wF6","lamports":1000000}},"stackHeight":null}],"recentBlockhash":"RBh1transfer1111111111111111111111111111111"}},"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[100000000,0,1],"postBalances":[98995000,1000000,1],"innerInstructions":[],"logMessages":["Program 11111111111111111111111111111111 invoke [1]","Program 11111111111111111111111111111111 success"],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":[],"readonly":[]}},"version":"legacy"},"id":1}"""; private const string Inner = - """{"jsonrpc":"2.0","result":{"slot":250000001,"blockTime":1700000001,"transaction":{"signatures":["sig2bbbb"],"message":{"accountKeys":[{"pubkey":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8","signer":true,"writable":true,"source":"transaction"},{"pubkey":"GE3oyzjSohCRBKq75a2ug4pDFx7GGKJXsz1GfQr836uP","signer":false,"writable":true,"source":"transaction"},{"pubkey":"Gdc1ZJMLFqN3f3xMDu8Sm6KJ7NNQzJ2GbmLBKUU7pCs4","signer":false,"writable":true,"source":"transaction"},{"pubkey":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","signer":false,"writable":false,"source":"transaction"},{"pubkey":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"programId":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","accounts":["GE3oyzjSohCRBKq75a2ug4pDFx7GGKJXsz1GfQr836uP","Gdc1ZJMLFqN3f3xMDu8Sm6KJ7NNQzJ2GbmLBKUU7pCs4","67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8"],"data":"3Bxs4h24hBtQy9rw","stackHeight":null}],"recentBlockhash":"RBh2inner11111111111111111111111111111111111"}},"meta":{"err":null,"fee":5000,"preBalances":[100000000,2039280,2039280,1,1],"postBalances":[99995000,2039280,2039280,1,1],"innerInstructions":[{"index":0,"instructions":[{"program":"spl-token","programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","parsed":{"type":"transferChecked","info":{"source":"GE3oyzjSohCRBKq75a2ug4pDFx7GGKJXsz1GfQr836uP","destination":"Gdc1ZJMLFqN3f3xMDu8Sm6KJ7NNQzJ2GbmLBKUU7pCs4","mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","tokenAmount":{"amount":"1000000","decimals":6,"uiAmount":1.0,"uiAmountString":"1"},"authority":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8"}},"stackHeight":2}]}],"logMessages":["Program X invoke [1]","Program Tokenkeg success"],"preTokenBalances":[{"accountIndex":1,"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","owner":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8","programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","uiTokenAmount":{"amount":"5000000","decimals":6,"uiAmount":5.0,"uiAmountString":"5"}}],"postTokenBalances":[{"accountIndex":1,"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","owner":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8","programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","uiTokenAmount":{"amount":"4000000","decimals":6,"uiAmount":4.0,"uiAmountString":"4"}},{"accountIndex":2,"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","owner":"9jLkNAaW9E47LQMHvjohy2uAAyr1331bAxgJKFRU7wF6","programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","uiTokenAmount":{"amount":"1000000","decimals":6,"uiAmount":1.0,"uiAmountString":"1"}}],"loadedAddresses":{"writable":[],"readonly":[]}},"version":0},"id":1}"""; + """{"jsonrpc":"2.0","result":{"slot":250000001,"blockTime":1700000001,"transaction":{"signatures":["sig2bbbb"],"message":{"accountKeys":[{"pubkey":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8","signer":true,"writable":true,"source":"transaction"},{"pubkey":"GE3oyzjSohCRBKq75a2ug4pDFx7GGKJXsz1GfQr836uP","signer":false,"writable":true,"source":"transaction"},{"pubkey":"Gdc1ZJMLFqN3f3xMDu8Sm6KJ7NNQzJ2GbmLBKUU7pCs4","signer":false,"writable":true,"source":"transaction"},{"pubkey":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","signer":false,"writable":false,"source":"transaction"},{"pubkey":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"programId":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","accounts":["GE3oyzjSohCRBKq75a2ug4pDFx7GGKJXsz1GfQr836uP","Gdc1ZJMLFqN3f3xMDu8Sm6KJ7NNQzJ2GbmLBKUU7pCs4","67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8"],"data":"3Bxs4h24hBtQy9rw","stackHeight":null}],"recentBlockhash":"RBh2inner11111111111111111111111111111111111"}},"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[100000000,2039280,2039280,1,1],"postBalances":[99995000,2039280,2039280,1,1],"innerInstructions":[{"index":0,"instructions":[{"program":"spl-token","programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","parsed":{"type":"transferChecked","info":{"source":"GE3oyzjSohCRBKq75a2ug4pDFx7GGKJXsz1GfQr836uP","destination":"Gdc1ZJMLFqN3f3xMDu8Sm6KJ7NNQzJ2GbmLBKUU7pCs4","mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","tokenAmount":{"amount":"1000000","decimals":6,"uiAmount":1.0,"uiAmountString":"1"},"authority":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8"}},"stackHeight":2}]}],"logMessages":["Program X invoke [1]","Program Tokenkeg success"],"preTokenBalances":[{"accountIndex":1,"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","owner":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8","programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","uiTokenAmount":{"amount":"5000000","decimals":6,"uiAmount":5.0,"uiAmountString":"5"}}],"postTokenBalances":[{"accountIndex":1,"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","owner":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8","programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","uiTokenAmount":{"amount":"4000000","decimals":6,"uiAmount":4.0,"uiAmountString":"4"}},{"accountIndex":2,"mint":"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","owner":"9jLkNAaW9E47LQMHvjohy2uAAyr1331bAxgJKFRU7wF6","programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","uiTokenAmount":{"amount":"1000000","decimals":6,"uiAmount":1.0,"uiAmountString":"1"}}],"loadedAddresses":{"writable":[],"readonly":[]}},"version":0},"id":1}"""; private const string Versioned = - """{"jsonrpc":"2.0","result":{"slot":250000002,"blockTime":1700000002,"transaction":{"signatures":["sig3cccc"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":"transaction"},{"pubkey":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","signer":false,"writable":false,"source":"transaction"},{"pubkey":"5CTyWy6H2GiE3mNp8aJjUVqu7eH2JRXbDqNhpVPkRBvo","signer":false,"writable":true,"source":"lookupTable"},{"pubkey":"Fydr76JtKYEyFnzTvoEJbKpfgfaWC29XSPWibA4SzEFu","signer":false,"writable":false,"source":"lookupTable"}],"instructions":[{"programId":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","accounts":["5CTyWy6H2GiE3mNp8aJjUVqu7eH2JRXbDqNhpVPkRBvo","Fydr76JtKYEyFnzTvoEJbKpfgfaWC29XSPWibA4SzEFu"],"data":"ABCD","stackHeight":null}],"recentBlockhash":"RBh3v01111111111111111111111111111111111111"}},"meta":{"err":null,"fee":5000,"preBalances":[1,1,1,1],"postBalances":[1,1,1,1],"innerInstructions":[],"logMessages":[],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":["5CTyWy6H2GiE3mNp8aJjUVqu7eH2JRXbDqNhpVPkRBvo"],"readonly":["Fydr76JtKYEyFnzTvoEJbKpfgfaWC29XSPWibA4SzEFu"]}},"version":0},"id":1}"""; + """{"jsonrpc":"2.0","result":{"slot":250000002,"blockTime":1700000002,"transaction":{"signatures":["sig3cccc"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":"transaction"},{"pubkey":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","signer":false,"writable":false,"source":"transaction"},{"pubkey":"5CTyWy6H2GiE3mNp8aJjUVqu7eH2JRXbDqNhpVPkRBvo","signer":false,"writable":true,"source":"lookupTable"},{"pubkey":"Fydr76JtKYEyFnzTvoEJbKpfgfaWC29XSPWibA4SzEFu","signer":false,"writable":false,"source":"lookupTable"}],"instructions":[{"programId":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","accounts":["5CTyWy6H2GiE3mNp8aJjUVqu7eH2JRXbDqNhpVPkRBvo","Fydr76JtKYEyFnzTvoEJbKpfgfaWC29XSPWibA4SzEFu"],"data":"ABCD","stackHeight":null}],"recentBlockhash":"RBh3v01111111111111111111111111111111111111"}},"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[1,1,1,1],"postBalances":[1,1,1,1],"innerInstructions":[],"logMessages":[],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":["5CTyWy6H2GiE3mNp8aJjUVqu7eH2JRXbDqNhpVPkRBvo"],"readonly":["Fydr76JtKYEyFnzTvoEJbKpfgfaWC29XSPWibA4SzEFu"]}},"version":0},"id":1}"""; private const string Memo = - """{"jsonrpc":"2.0","result":{"slot":250000003,"blockTime":1700000003,"transaction":{"signatures":["sigMemo"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":"transaction"},{"pubkey":"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"program":"spl-memo","programId":"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr","parsed":"gm wagmi","stackHeight":null}],"recentBlockhash":"RBhMemo1111111111111111111111111111111111111"}},"meta":{"err":null,"fee":5000,"preBalances":[1,1],"postBalances":[1,1],"innerInstructions":[],"logMessages":[],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":[],"readonly":[]}},"version":"legacy"},"id":1}"""; + """{"jsonrpc":"2.0","result":{"slot":250000003,"blockTime":1700000003,"transaction":{"signatures":["sigMemo"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":"transaction"},{"pubkey":"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"program":"spl-memo","programId":"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr","parsed":"gm wagmi","stackHeight":null}],"recentBlockhash":"RBhMemo1111111111111111111111111111111111111"}},"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[1,1],"postBalances":[1,1],"innerInstructions":[],"logMessages":[],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":[],"readonly":[]}},"version":"legacy"},"id":1}"""; private const string Failed = - """{"jsonrpc":"2.0","result":{"slot":250000004,"blockTime":1700000004,"transaction":{"signatures":["sigFail"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":"transaction"},{"pubkey":"11111111111111111111111111111111","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"program":"system","programId":"11111111111111111111111111111111","parsed":{"type":"transfer","info":{"lamports":1}},"stackHeight":null}],"recentBlockhash":"RBhFail1111111111111111111111111111111111111"}},"meta":{"err":{"InstructionError":[0,{"Custom":6001}]},"fee":5000,"preBalances":[1,1],"postBalances":[1,1],"innerInstructions":[],"logMessages":["Program failed"],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":[],"readonly":[]}},"version":"legacy"},"id":1}"""; + """{"jsonrpc":"2.0","result":{"slot":250000004,"blockTime":1700000004,"transaction":{"signatures":["sigFail"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":"transaction"},{"pubkey":"11111111111111111111111111111111","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"program":"system","programId":"11111111111111111111111111111111","parsed":{"type":"transfer","info":{"lamports":1}},"stackHeight":null}],"recentBlockhash":"RBhFail1111111111111111111111111111111111111"}},"meta":{"err":{"InstructionError":[0,{"Custom":6001}]},"status":{"Err":{"InstructionError":[0,{"Custom":6001}]}},"fee":5000,"preBalances":[1,1],"postBalances":[1,1],"innerInstructions":[],"logMessages":["Program failed"],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":[],"readonly":[]}},"version":"legacy"},"id":1}"""; private const string InnerMemo = - """{"jsonrpc":"2.0","result":{"slot":250000005,"blockTime":1700000005,"transaction":{"signatures":["sigInnerMemo"],"message":{"accountKeys":[{"pubkey":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8","signer":true,"writable":true,"source":"transaction"},{"pubkey":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"programId":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","accounts":["67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8"],"data":"3Bxs","stackHeight":null}],"recentBlockhash":"RBhInner111111111111111111111111111111111111"}},"meta":{"err":null,"fee":5000,"preBalances":[1,1],"postBalances":[1,1],"innerInstructions":[{"index":0,"instructions":[{"program":"spl-memo","programId":"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr","parsed":"cpi memo","stackHeight":2}]}],"logMessages":[],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":[],"readonly":[]}},"version":0},"id":1}"""; + """{"jsonrpc":"2.0","result":{"slot":250000005,"blockTime":1700000005,"transaction":{"signatures":["sigInnerMemo"],"message":{"accountKeys":[{"pubkey":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8","signer":true,"writable":true,"source":"transaction"},{"pubkey":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"programId":"7QMhYQAPfkoURcrQFxgHKXbipaYL4Sj34kweHx3d3J67","accounts":["67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8"],"data":"3Bxs","stackHeight":null}],"recentBlockhash":"RBhInner111111111111111111111111111111111111"}},"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[1,1],"postBalances":[1,1],"innerInstructions":[{"index":0,"instructions":[{"program":"spl-memo","programId":"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr","parsed":"cpi memo","stackHeight":2}]}],"logMessages":[],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":[],"readonly":[]}},"version":0},"id":1}"""; private const string NotFound = """{"jsonrpc":"2.0","result":null,"id":1}"""; private const string Malformed = - """{"jsonrpc":"2.0","result":{"transaction":{"signatures":["sigX"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true}],"instructions":[],"recentBlockhash":"RBh4mal111111111111111111111111111111111111"}}},"id":1}"""; + """{"jsonrpc":"2.0","result":{"slot":0,"blockTime":null,"transaction":{"signatures":["sigX"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":null}],"instructions":[],"recentBlockhash":"RBh4mal111111111111111111111111111111111111"}},"meta":null},"id":1}"""; private const string BlockJson = - """{"jsonrpc":"2.0","result":{"blockhash":"BHash5block11111111111111111111111111111111","previousBlockhash":"BHash6parent1111111111111111111111111111111","parentSlot":249999999,"blockHeight":123456,"blockTime":1700000005,"transactions":[{"transaction":{"signatures":["sigA"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":"transaction"},{"pubkey":"9jLkNAaW9E47LQMHvjohy2uAAyr1331bAxgJKFRU7wF6","signer":false,"writable":true,"source":"transaction"},{"pubkey":"11111111111111111111111111111111","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"program":"system","programId":"11111111111111111111111111111111","parsed":{"type":"transfer","info":{"lamports":42}},"stackHeight":null}],"recentBlockhash":"RBh7blktx111111111111111111111111111111111"}},"meta":{"err":null,"fee":5000,"preBalances":[1,1,1],"postBalances":[1,1,1],"innerInstructions":[],"logMessages":[],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":[],"readonly":[]}},"version":"legacy"},{"transaction":{"signatures":["sigB"],"message":{"accountKeys":[{"pubkey":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8","signer":true,"writable":true,"source":"transaction"}],"instructions":[],"recentBlockhash":"RBh8blktx211111111111111111111111111111111"}},"meta":null,"version":0}]},"id":1}"""; + """{"jsonrpc":"2.0","result":{"blockhash":"BHash5block11111111111111111111111111111111","previousBlockhash":"BHash6parent1111111111111111111111111111111","parentSlot":249999999,"blockHeight":123456,"blockTime":1700000005,"numRewardPartitions":4,"transactions":[{"transaction":{"signatures":["sigA"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":"transaction"},{"pubkey":"9jLkNAaW9E47LQMHvjohy2uAAyr1331bAxgJKFRU7wF6","signer":false,"writable":true,"source":"transaction"},{"pubkey":"11111111111111111111111111111111","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"program":"system","programId":"11111111111111111111111111111111","parsed":{"type":"transfer","info":{"lamports":42}},"stackHeight":null}],"recentBlockhash":"RBh7blktx111111111111111111111111111111111"}},"meta":{"err":null,"status":{"Ok":null},"fee":5000,"preBalances":[1,1,1],"postBalances":[1,1,1],"innerInstructions":[],"logMessages":[],"preTokenBalances":[],"postTokenBalances":[],"loadedAddresses":{"writable":[],"readonly":[]}},"version":"legacy"},{"transaction":{"signatures":["sigB"],"message":{"accountKeys":[{"pubkey":"67vHA8qZGCJKw1UNGUJZME4MwEWDRGWzp7MGvsut43A8","signer":true,"writable":true,"source":"transaction"}],"instructions":[],"recentBlockhash":"RBh8blktx211111111111111111111111111111111"}},"meta":null,"version":0}]},"id":1}"""; } diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientResponseLimitTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientResponseLimitTests.cs new file mode 100644 index 0000000..7cb9242 --- /dev/null +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientResponseLimitTests.cs @@ -0,0 +1,175 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace SolSharp.Rpc.Tests; + +public static class SolanaRpcClientResponseLimitTests +{ + private const string SlotResponse = "{\"jsonrpc\":\"2.0\",\"result\":123,\"id\":1}"; + + [TestFixture] + public sealed class SingleRequest + { + [Test] + public async Task DeclaredResponseBeyondConfiguredLimit_Throws() + { + var http = new HttpClient(new FakeHttpMessageHandler(SlotResponse)) + { + BaseAddress = new Uri("http://localhost") + }; + var client = new SolanaRpcClient(http, maximumResponseContentLength: 16); + + Func act = async () => await client.GetSlotAsync(); + + await act.Should().ThrowAsync() + .WithMessage("*16-byte limit*"); + } + + [Test] + public async Task UnknownLengthResponseAtConfiguredLimit_IsAccepted() + { + var content = UnknownLengthContent(SlotResponse); + var handler = new SequenceHandler(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + var http = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }; + var client = new SolanaRpcClient( + http, maximumResponseContentLength: Encoding.UTF8.GetByteCount(SlotResponse)); + content.Headers.ContentLength.Should().BeNull(); + + (await client.GetSlotAsync()).Should().Be(123); + } + + [Test] + public async Task UnknownLengthResponseOneByteBeyondConfiguredLimit_ThrowsWhileReading() + { + var content = UnknownLengthContent(SlotResponse); + var handler = new SequenceHandler(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + var http = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }; + var limit = Encoding.UTF8.GetByteCount(SlotResponse) - 1; + var client = new SolanaRpcClient(http, maximumResponseContentLength: limit); + content.Headers.ContentLength.Should().BeNull(); + + Func act = async () => await client.GetSlotAsync(); + + await act.Should().ThrowAsync() + .WithMessage($"*{limit}-byte limit*"); + } + + [Test] + public async Task GenerousDefaultAcceptsLargeLegitimatePayload() + { + var paddedResponse = SlotResponse.PadRight(256 * 1024, ' '); + var http = new HttpClient(new FakeHttpMessageHandler(paddedResponse)) + { + BaseAddress = new Uri("http://localhost") + }; + var client = new SolanaRpcClient(http); + + (await client.GetSlotAsync()).Should().Be(123); + } + } + + [TestFixture] + public sealed class BatchRequest + { + [Test] + public async Task ResponseBeyondConfiguredLimit_ThrowsAndFaultsQueuedCalls() + { + const string response = "[{\"jsonrpc\":\"2.0\",\"result\":123,\"id\":1}]"; + var http = new HttpClient(new FakeHttpMessageHandler(response)) + { + BaseAddress = new Uri("http://localhost") + }; + var client = new SolanaRpcClient(http, maximumResponseContentLength: 16); + var batch = client.CreateBatch(); + var call = batch.GetSlotAsync(); + + var act = () => batch.ExecuteAsync(); + + await act.Should().ThrowAsync() + .WithMessage("*16-byte limit*"); + call.IsFaulted.Should().BeTrue(); + } + } + + [TestFixture] + public sealed class DependencyInjection + { + [Test] + public async Task ConfiguredLimitFlowsThroughTypedClientActivation() + { + var services = new ServiceCollection(); + services + .AddSolanaRpc(options => + { + options.Endpoint = "https://node.example"; + options.MaximumResponseContentLength = 16; + }) + .ConfigurePrimaryHttpMessageHandler(() => new FakeHttpMessageHandler(SlotResponse)); + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + Func act = async () => await client.GetSlotAsync(); + + await act.Should().ThrowAsync() + .WithMessage("*16-byte limit*"); + } + } + + private static StreamContent UnknownLengthContent(string body) + { + var content = new StreamContent(new NonSeekableReadStream(Encoding.UTF8.GetBytes(body))); + content.Headers.ContentType = new("application/json"); + return content; + } + + private sealed class NonSeekableReadStream(byte[] data) : Stream + { + private readonly MemoryStream _inner = new(data, writable: false); + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + => _inner.Read(buffer, offset, count); + + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + => _inner.ReadAsync(buffer, cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + _inner.Dispose(); + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await _inner.DisposeAsync(); + await base.DisposeAsync(); + } + } +} diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientSignaturesTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientSignaturesTests.cs index 2214394..406f298 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientSignaturesTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientSignaturesTests.cs @@ -23,7 +23,7 @@ public async Task ParsesEntriesAndRequestsTheAddress() { // Arrange var (client, handler) = Make( - """{"jsonrpc":"2.0","result":[{"signature":"sig11","slot":100,"err":null,"memo":null,"blockTime":1700000000,"confirmationStatus":"finalized"}],"id":1}"""); + """{"jsonrpc":"2.0","result":[{"signature":"sig11","slot":100,"err":null,"memo":null,"blockTime":1700000000,"confirmationStatus":"finalized","transactionIndex":17}],"id":1}"""); // Act var signatures = await client.GetSignaturesForAddressAsync(PublicKey.Parse(Address)); @@ -34,6 +34,7 @@ public async Task ParsesEntriesAndRequestsTheAddress() signatures[0].Slot.Should().Be(100); signatures[0].BlockTime.Should().Be(1700000000); signatures[0].ConfirmationStatus.Should().Be("finalized"); + signatures[0].TransactionIndex.Should().Be(17); signatures[0].IsError.Should().BeFalse(); handler.CapturedRequestBody.Should().Contain("\"getSignaturesForAddress\""); @@ -48,7 +49,7 @@ public async Task SurfacesErrAsIsError() { // Arrange var (client, _) = Make( - """{"jsonrpc":"2.0","result":[{"signature":"sigErr","slot":5,"err":{"InstructionError":[0,"Custom"]}}],"id":1}"""); + """{"jsonrpc":"2.0","result":[{"signature":"sigErr","slot":5,"err":{"InstructionError":[0,"Custom"]},"memo":null,"blockTime":null,"confirmationStatus":null}],"id":1}"""); // Act var signatures = await client.GetSignaturesForAddressAsync(PublicKey.Parse(Address)); @@ -57,6 +58,32 @@ public async Task SurfacesErrAsIsError() signatures[0].IsError.Should().BeTrue(); } + [Test] + public async Task MissingMandatoryEntryFields_ThrowsJsonException() + { + // Arrange + var (client, _) = Make("""{"jsonrpc":"2.0","result":[{}],"id":1}"""); + + // Act + var act = async () => await client.GetSignaturesForAddressAsync(PublicKey.Parse(Address)); + + // Assert + await act.Should().ThrowAsync(); + } + + [Test] + public async Task NullEntry_ThrowsJsonException() + { + // Arrange + var (client, _) = Make("""{"jsonrpc":"2.0","result":[null],"id":1}"""); + + // Act + var act = async () => await client.GetSignaturesForAddressAsync(PublicKey.Parse(Address)); + + // Assert + await act.Should().ThrowAsync(); + } + [Test] public async Task SendsLimitAndBeforeWhenProvided() { diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientSourceCompatibilityTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientSourceCompatibilityTests.cs new file mode 100644 index 0000000..c7ef3d5 --- /dev/null +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientSourceCompatibilityTests.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; +using SolSharp.Rpc.Streaming; + +namespace SolSharp.Rpc.Tests; + +public static class SolanaRpcClientSourceCompatibilityTests +{ + [TestFixture] + public sealed class PositionalDefaultLiterals + { + [Test] + public void BindToLegacyOverloadsAtCompileTime() + { + // Taking the method containing these calls as a delegate compiles every expression without executing it. + // Reintroducing a same-name reference-options overload makes this file fail with CS0121. + Action bindingProbe = BindLegacyCalls; + + bindingProbe.Should().NotBeNull(); + } + + private static void BindLegacyCalls(SolanaRpcClient rpc, SolanaWsClient ws, PublicKey account) + { + _ = rpc.GetLatestBlockhashAsync(default); + _ = rpc.GetBalanceAsync(account, default); + _ = rpc.GetSlotAsync(default); + _ = rpc.GetBlockHeightAsync(default); + _ = rpc.GetTransactionCountAsync(default); + _ = rpc.GetAccountInfoAsync(account, default); + _ = rpc.GetMultipleAccountsAsync([account], default); + _ = rpc.GetProgramAccountsAsync(account, default); + _ = rpc.GetEpochInfoAsync(default); + _ = rpc.IsBlockhashValidAsync("hash", default); + _ = rpc.GetFeeForMessageAsync([1], default); + _ = rpc.RequestAirdropAsync(account, 1, default); + _ = rpc.GetTokenAccountsByOwnerAsync(account, default); + _ = rpc.GetTransactionAsync("signature", default); + _ = rpc.GetSupplyAsync(default); + _ = rpc.GetBlockAsync(1, default); + _ = rpc.GetVoteAccountsAsync(default); + _ = rpc.GetInflationRewardAsync([account], default); + _ = rpc.GetLeaderScheduleAsync(default); + _ = rpc.GetBlocksAsync(1, 2, default); + _ = rpc.GetBlocksWithLimitAsync(1, 2, default); + _ = rpc.GetLargestAccountsAsync(default); + _ = rpc.GetSlotLeaderAsync(default); + _ = rpc.GetStakeMinimumDelegationAsync(default); + _ = rpc.GetTokenAccountsByDelegateAsync(account, default); + _ = rpc.GetParsedAccountInfoAsync(account, default); + _ = ws.SubscribeLogsAsync(default); + _ = ws.SubscribeSignatureAsync("signature", default); + _ = ws.SubscribeBlocksAsync(default, default, default); + } + } +} diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientTests.cs index c8fc014..b6d2db7 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientTests.cs @@ -35,6 +35,20 @@ public async Task ParsesBlockhashAndHeightFromContextValue() result.Blockhash.Should().Be("EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N"); result.LastValidBlockHeight.Should().Be(3090); } + + [Test] + public async Task NullContextValue_ThrowsJsonException() + { + // Arrange + var client = Client( + """{"jsonrpc":"2.0","result":{"context":{"slot":328},"value":null},"id":1}"""); + + // Act + var act = async () => await client.GetLatestBlockhashAsync(); + + // Assert + await act.Should().ThrowAsync(); + } } [TestFixture] @@ -102,6 +116,19 @@ public async Task ParsesSolanaCore() // Act & Assert (await client.GetVersionAsync()).SolanaCore.Should().Be("2.0.14"); } + + [Test] + public async Task NullResult_ThrowsJsonException() + { + // Arrange + var client = Client("""{"jsonrpc":"2.0","result":null,"id":1}"""); + + // Act + var act = async () => await client.GetVersionAsync(); + + // Assert + await act.Should().ThrowAsync(); + } } [TestFixture] @@ -135,6 +162,20 @@ public async Task ParsesAmountAndDecimals() supply.Amount.Should().Be("1000000000"); supply.Decimals.Should().Be(6); } + + [Test] + public async Task MissingMandatoryAmountFields_ThrowsJsonException() + { + // Arrange + var client = Client( + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{}},"id":1}"""); + + // Act + var act = async () => await client.GetTokenSupplyAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); + + // Assert + await act.Should().ThrowAsync(); + } } [TestFixture] @@ -200,6 +241,23 @@ public async Task NodeError_ThrowsRpcExceptionWithCode() (await act.Should().ThrowAsync()).Which.Code.Should().Be(-32601); } + [Test] + public async Task NodeError_PreservesStructuredErrorData() + { + // Arrange + var client = Client( + """{"jsonrpc":"2.0","error":{"code":-32002,"message":"Simulation failed","data":{"logs":["Program log: rejected"],"unitsConsumed":321}},"id":1}"""); + + // Act + var act = async () => await client.GetSlotAsync(); + + // Assert + var exception = (await act.Should().ThrowAsync()).Which; + exception.ErrorData.Should().NotBeNull(); + exception.ErrorData!.Value.GetProperty("logs")[0].GetString().Should().Be("Program log: rejected"); + exception.ErrorData.Value.GetProperty("unitsConsumed").GetInt32().Should().Be(321); + } + [Test] public async Task WrongProtocolVersion_ThrowsRpcException() { @@ -258,6 +316,21 @@ await act.Should().ThrowAsync() .WithMessage("RPC error -32005: Node is behind"); } + [Test] + public async Task NonNullResultAndError_ThrowsProtocolError() + { + // Arrange + var client = Client( + "{\"jsonrpc\":\"2.0\",\"result\":123,\"error\":{\"code\":-32005,\"message\":\"Node is behind\"},\"id\":1}"); + + // Act + var act = async () => await client.GetSlotAsync(); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("RPC error -1: JSON-RPC response contained both a non-null result and an error."); + } + [Test] public async Task ErrorWithNullId_SurfacesTheNodeError() { @@ -272,6 +345,56 @@ await act.Should().ThrowAsync() .WithMessage("RPC error -32700: Parse error"); } + [Test] + public async Task ErrorWithMismatchedNumericId_ThrowsProtocolError() + { + // Arrange + var client = Client( + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32005,\"message\":\"Other request\"},\"id\":2}"); + + // Act + var act = async () => await client.GetSlotAsync(); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("RPC error -1: JSON-RPC response id did not match the request id."); + } + + [Test] + public async Task ErrorWithWrongProtocolVersion_ThrowsProtocolError() + { + // Arrange + var client = Client( + "{\"jsonrpc\":\"1.0\",\"error\":{\"code\":-32005,\"message\":\"Node is behind\"},\"id\":1}"); + + // Act + var act = async () => await client.GetSlotAsync(); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("RPC error -1: Invalid JSON-RPC response version."); + } + + [TestCase("{}")] + [TestCase("{\"code\":-1}")] + [TestCase("{\"message\":\"bad\"}")] + [TestCase("{\"code\":\"-1\",\"message\":\"bad\"}")] + [TestCase("{\"code\":-1,\"message\":null}")] + public async Task MalformedErrorObject_ThrowsProtocolError(string error) + { + // Arrange + var client = Client( + """{"jsonrpc":"2.0","error":__ERROR__,"id":1}""" + .Replace("__ERROR__", error, StringComparison.Ordinal)); + + // Act + var act = async () => await client.GetSlotAsync(); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("RPC error -1: JSON-RPC response carried a malformed error object."); + } + [Test] public async Task EmptyBody_ThrowsRpcException() { diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientTokenAccountsTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientTokenAccountsTests.cs index 2958346..429847c 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientTokenAccountsTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientTokenAccountsTests.cs @@ -65,5 +65,18 @@ public async Task ParsesFees() fees[1].Fee.Should().Be(0); handler.CapturedRequestBody.Should().Contain("\"getRecentPrioritizationFees\""); } + + [Test] + public async Task NullEntry_ThrowsJsonException() + { + // Arrange + var (client, _) = Make("""{"jsonrpc":"2.0","result":[null],"id":1}"""); + + // Act + var act = async () => await client.GetRecentPrioritizationFeesAsync(); + + // Assert + await act.Should().ThrowAsync(); + } } } diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientTokenStateTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientTokenStateTests.cs index fb44323..e1cab80 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientTokenStateTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientTokenStateTests.cs @@ -1,5 +1,7 @@ +using System.Buffers.Binary; using FluentAssertions; using NUnit.Framework; +using SolSharp.Core.Constants; using SolSharp.Core.Primitives; using SolSharp.Rpc.Models; @@ -31,9 +33,18 @@ private static (SolanaRpcClient Client, FakeHttpMessageHandler Handler) Make(str return (new SolanaRpcClient(http), handler); } - private static string AccountEnvelope(string dataBase64) => - """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"data":["__DATA__","base64"],"executable":false,"lamports":1,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":0}},"id":1}""" - .Replace("__DATA__", dataBase64); + private static string AccountEnvelope(string dataBase64, string owner = SolanaProgramIds.TokenProgram) => + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"data":["__DATA__","base64"],"executable":false,"lamports":1,"owner":"__OWNER__","rentEpoch":0,"space":0}},"id":1}""" + .Replace("__DATA__", dataBase64) + .Replace("__OWNER__", owner); + + private static byte[] Token2022Data(string base64, byte accountType) + { + var data = new byte[166]; + Convert.FromBase64String(base64).CopyTo(data, 0); + data[165] = accountType; + return data; + } [TestFixture] public sealed class MintDecode @@ -52,6 +63,35 @@ public void DecodesMint_MatchingSolders() mint.IsInitialized.Should().BeTrue(); mint.FreezeAuthority.Should().BeNull(); } + + [Test] + public void TokenAccountLayout_ReturnsNull() + => Mint.Decode(Convert.FromBase64String(TokenAccountBase64)).Should().BeNull(); + + [Test] + public void InvalidCOptionOrBoolean_ReturnsNull() + { + var invalidOption = Convert.FromBase64String(MintBase64); + BinaryPrimitives.WriteUInt32LittleEndian(invalidOption, 2); + var invalidBoolean = Convert.FromBase64String(MintBase64); + invalidBoolean[45] = 2; + + Mint.Decode(invalidOption).Should().BeNull(); + Mint.Decode(invalidBoolean).Should().BeNull(); + } + + [Test] + public void Token2022NonZeroMintPaddingOrMultisigLength_ReturnsNull() + { + var invalidPadding = Token2022Data(MintBase64, accountType: 1); + invalidPadding[Mint.Length] = 1; + var multisigLength = new byte[355]; + Convert.FromBase64String(MintBase64).CopyTo(multisigLength, 0); + multisigLength[165] = 1; + + Mint.Decode(invalidPadding).Should().BeNull(); + Mint.Decode(multisigLength).Should().BeNull(); + } } [TestFixture] @@ -76,6 +116,28 @@ public void DecodesTokenAccount_MatchingSolders() account.CloseAuthority.Should().BeNull(); account.IsFrozen.Should().BeFalse(); } + + [Test] + public void InvalidCOptionOrState_ReturnsNull() + { + var invalidOption = Convert.FromBase64String(TokenAccountBase64); + BinaryPrimitives.WriteUInt32LittleEndian(invalidOption.AsSpan(72), 2); + var invalidState = Convert.FromBase64String(TokenAccountBase64); + invalidState[108] = 3; + + TokenAccount.Decode(invalidOption).Should().BeNull(); + TokenAccount.Decode(invalidState).Should().BeNull(); + } + + [Test] + public void Token2022MultisigLength_ReturnsNull() + { + var data = new byte[355]; + Convert.FromBase64String(TokenAccountBase64).CopyTo(data, 0); + data[165] = 2; + + TokenAccount.Decode(data).Should().BeNull(); + } } [TestFixture] @@ -95,6 +157,28 @@ public async Task FetchesAndDecodes() mint!.Decimals.Should().Be(6); handler.CapturedRequestBody.Should().Contain("\"getAccountInfo\""); } + + [Test] + public async Task WrongOwnerOrTokenAccountLayout_ReturnsNull() + { + var (wrongOwnerClient, _) = Make(AccountEnvelope(MintBase64, SolanaProgramIds.SystemProgram)); + var (tokenAccountClient, _) = Make(AccountEnvelope(TokenAccountBase64)); + + (await wrongOwnerClient.GetMintAsync(Pk(1))).Should().BeNull(); + (await tokenAccountClient.GetMintAsync(Pk(1))).Should().BeNull(); + } + + [Test] + public async Task Token2022ExtendedMint_Decodes() + { + var data = Token2022Data(MintBase64, accountType: 1); + var (client, _) = Make(AccountEnvelope(Convert.ToBase64String(data), SolanaProgramIds.Token2022Program)); + + var mint = await client.GetMintAsync(Pk(1)); + + mint.Should().NotBeNull(); + mint!.Decimals.Should().Be(6); + } } [TestFixture] @@ -113,5 +197,28 @@ public async Task FetchesAndDecodes() account.Should().NotBeNull(); account!.Amount.Should().Be(5_000_000ul); } + + [Test] + public async Task WrongOwnerOrMintLayout_ReturnsNull() + { + var (wrongOwnerClient, _) = Make(AccountEnvelope(TokenAccountBase64, SolanaProgramIds.SystemProgram)); + var extendedMint = Token2022Data(MintBase64, accountType: 1); + var (mintClient, _) = Make(AccountEnvelope(Convert.ToBase64String(extendedMint), SolanaProgramIds.Token2022Program)); + + (await wrongOwnerClient.GetTokenAccountAsync(Pk(2))).Should().BeNull(); + (await mintClient.GetTokenAccountAsync(Pk(2))).Should().BeNull(); + } + + [Test] + public async Task Token2022ExtendedAccount_Decodes() + { + var data = Token2022Data(TokenAccountBase64, accountType: 2); + var (client, _) = Make(AccountEnvelope(Convert.ToBase64String(data), SolanaProgramIds.Token2022Program)); + + var account = await client.GetTokenAccountAsync(Pk(2)); + + account.Should().NotBeNull(); + account!.Amount.Should().Be(5_000_000ul); + } } } diff --git a/tests/SolSharp.Rpc.Tests/SolanaRpcClientTransactionTests.cs b/tests/SolSharp.Rpc.Tests/SolanaRpcClientTransactionTests.cs index 709dcfd..b88fc25 100644 --- a/tests/SolSharp.Rpc.Tests/SolanaRpcClientTransactionTests.cs +++ b/tests/SolSharp.Rpc.Tests/SolanaRpcClientTransactionTests.cs @@ -1,6 +1,9 @@ +using System.Text.Json; using FluentAssertions; using NUnit.Framework; +using SolSharp.Core.Constants; using SolSharp.Core.Primitives; +using SolSharp.Rpc.Models; namespace SolSharp.Rpc.Tests; @@ -107,6 +110,20 @@ public async Task SurfacesErrAsIsError() result.IsError.Should().BeTrue(); } + [Test] + public async Task MissingErrorMember_ThrowsJsonException() + { + // Arrange + var (client, _) = Make( + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{}},"id":1}"""); + + // Act + var act = async () => await client.SimulateTransactionAsync([9]); + + // Assert + await act.Should().ThrowAsync(); + } + [Test] public async Task DefaultsCommitmentToConfirmed() { @@ -119,6 +136,7 @@ public async Task DefaultsCommitmentToConfirmed() // Assert handler.CapturedRequestBody.Should().Contain("\"commitment\":\"confirmed\""); + handler.CapturedRequestBody.Should().NotContain("\"innerInstructions\""); } [Test] @@ -130,7 +148,6 @@ public async Task SendsOptionsWhenProvided() var options = new SimulateTransactionOptions { SigVerify = true, - ReplaceRecentBlockhash = true, Commitment = Commitment.Processed, MinContextSlot = 7 }; @@ -140,9 +157,182 @@ public async Task SendsOptionsWhenProvided() // Assert handler.CapturedRequestBody.Should().Contain("\"sigVerify\":true"); - handler.CapturedRequestBody.Should().Contain("\"replaceRecentBlockhash\":true"); + handler.CapturedRequestBody.Should().Contain("\"replaceRecentBlockhash\":false"); handler.CapturedRequestBody.Should().Contain("\"commitment\":\"processed\""); handler.CapturedRequestBody.Should().Contain("\"minContextSlot\":7"); } + + [Test] + public async Task SendsAccountAndInnerInstructionOptions_AndParsesFullCurrentResult() + { + // Arrange + var system = PublicKey.Parse(SolanaProgramIds.SystemProgram); + var token = PublicKey.Parse(SolanaProgramIds.TokenProgram); + var (client, handler) = Make( + """{"jsonrpc":"2.0","result":{"context":{"slot":88,"apiVersion":"3.1.7"},"value":{"err":null,"logs":["ok"],"accounts":[{"lamports":9,"data":["AQID","base64"],"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":18446744073709551615,"space":3}],"unitsConsumed":1234,"loadedAccountsDataSize":456,"returnData":{"programId":"11111111111111111111111111111111","data":["BAU=","base64"]},"innerInstructions":[{"index":0,"instructions":[{"program":"system","programId":"11111111111111111111111111111111","parsed":{"type":"transfer","info":{"lamports":1}},"stackHeight":2}]}],"replacementBlockhash":{"blockhash":"CktRuQ2mttgRGkXJtyksdKHjUdc2C4TgDzyB98oEzy8","lastValidBlockHeight":999},"fee":5000,"preBalances":[10,20],"postBalances":[5,20],"preTokenBalances":[{"accountIndex":1,"mint":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","uiTokenAmount":{"amount":"10","decimals":1,"uiAmount":1.0,"uiAmountString":"1"}}],"postTokenBalances":[],"loadedAddresses":{"writable":["11111111111111111111111111111111"],"readonly":["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"]}}},"id":1}"""); + var options = new SimulateTransactionOptions + { + Accounts = [system, token], + InnerInstructions = true, + ReplaceRecentBlockhash = true + }; + + // Act + var result = await client.SimulateTransactionAsync([1, 2, 3], options); + + // Assert + using var request = JsonDocument.Parse(handler.CapturedRequestBody!); + var config = request.RootElement.GetProperty("params")[1]; + config.GetProperty("innerInstructions").GetBoolean().Should().BeTrue(); + config.GetProperty("accounts").GetProperty("encoding").GetString().Should().Be("base64"); + config.GetProperty("accounts").GetProperty("addresses").EnumerateArray() + .Select(static address => address.GetString()).Should().Equal(system.ToString(), token.ToString()); + + result.Accounts.Should().ContainSingle(); + var accountData = result.Accounts![0]!.Data.Should().BeOfType().Subject; + accountData.Encoding.Should().Be(RpcAccountEncoding.Base64); + Convert.FromBase64String(accountData.EncodedData).Should().Equal(1, 2, 3); + result.Accounts[0]!.Space.Should().Be(3); + result.LoadedAccountsDataSize.Should().Be(456); + result.ReturnData!.ProgramId.Should().Be(system); + result.ReturnData.Data.Should().Equal(4, 5); + var inner = result.InnerInstructions.Should().ContainSingle().Subject; + inner.Instructions.Should().ContainSingle().Which.Parsed!.Type.Should().Be("transfer"); + result.ReplacementBlockhash!.LastValidBlockHeight.Should().Be(999); + result.Fee.Should().Be(5000); + result.PreBalances.Should().Equal(10ul, 20ul); + result.PostBalances.Should().Equal(5ul, 20ul); + result.PreTokenBalances.Should().ContainSingle().Which.UiTokenAmount.Amount.Should().Be("10"); + result.PostTokenBalances.Should().BeEmpty(); + result.LoadedAddresses!.Writable.Should().ContainSingle().Which.Should().Be(system); + result.LoadedAddresses.Readonly.Should().ContainSingle().Which.Should().Be(token); + } + + [Test] + public async Task JsonParsedAccounts_SendExactEncodingAndPreserveParsedBranch() + { + // Arrange + var account = PublicKey.Parse(SolanaProgramIds.SystemProgram); + var (client, handler) = Make( + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"err":null,"accounts":[{"lamports":9,"data":{"program":"system","parsed":{"type":"nonce","info":{}},"space":80},"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":0,"space":80}]}},"id":1}"""); + var options = new SimulateTransactionOptions + { + Accounts = [account], + AccountsEncoding = RpcAccountEncoding.JsonParsed + }; + + // Act + var result = await client.SimulateTransactionAsync([1], options); + + // Assert + using var request = JsonDocument.Parse(handler.CapturedRequestBody!); + request.RootElement.GetProperty("params")[1].GetProperty("accounts") + .GetProperty("encoding").GetString().Should().Be("jsonParsed"); + var parsed = result.Accounts.Should().ContainSingle().Subject!.Data + .Should().BeOfType().Subject; + parsed.Program.Should().Be("system"); + parsed.Value.GetProperty("type").GetString().Should().Be("nonce"); + } + + [TestCase(RpcAccountEncoding.Binary)] + [TestCase(RpcAccountEncoding.Base58)] + [TestCase((RpcAccountEncoding)999)] + public async Task UnsupportedAccountEncoding_ThrowsBeforeSending(RpcAccountEncoding encoding) + { + // Arrange + var (client, handler) = Make( + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"err":null}},"id":1}"""); + var options = new SimulateTransactionOptions { Accounts = [], AccountsEncoding = encoding }; + + // Act + var act = async () => await client.SimulateTransactionAsync([1], options); + + // Assert + await act.Should().ThrowAsync().WithParameterName("options"); + handler.CapturedRequestBody.Should().BeNull(); + } + + [Test] + public async Task UnsupportedUnusedAccountEncoding_IsNotSentOrValidated() + { + // Arrange + var (client, handler) = Make( + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"err":null}},"id":1}"""); + var options = new SimulateTransactionOptions + { + AccountsEncoding = RpcAccountEncoding.Base58 + }; + + // Act + await client.SimulateTransactionAsync([1], options); + + // Assert + using var request = JsonDocument.Parse(handler.CapturedRequestBody!); + request.RootElement.GetProperty("params")[1].TryGetProperty("accounts", out _).Should().BeFalse(); + } + + [Test] + public async Task MalformedReturnDataEncoding_ThrowsJsonException() + { + // Arrange + var (client, _) = Make( + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"err":null,"returnData":{"programId":"11111111111111111111111111111111","data":["AQID","base58"]}}},"id":1}"""); + + // Act + var act = async () => await client.SimulateTransactionAsync([1]); + + // Assert + await act.Should().ThrowAsync().WithMessage("*base64*"); + } + + [Test] + public async Task MalformedReturnDataBytes_ThrowsJsonException() + { + // Arrange + var (client, _) = Make( + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"err":null,"returnData":{"programId":"11111111111111111111111111111111","data":["%%%","base64"]}}},"id":1}"""); + + // Act + var act = async () => await client.SimulateTransactionAsync([1]); + + // Assert + await act.Should().ThrowAsync().WithMessage("*Binary data*base64*"); + } + + [TestCase("\"logs\":[null]")] + [TestCase("\"innerInstructions\":[null]")] + [TestCase("\"preTokenBalances\":[null]")] + [TestCase("\"postTokenBalances\":[null]")] + public async Task NullEntryInOptionalResultCollection_ThrowsJsonException(string member) + { + // Arrange + var response = + "{\"jsonrpc\":\"2.0\",\"result\":{\"context\":{\"slot\":1},\"value\":{\"err\":null," + + member + "}},\"id\":1}"; + var (client, _) = Make(response); + + // Act + var act = async () => await client.SimulateTransactionAsync([1]); + + // Assert + await act.Should().ThrowAsync(); + } + + [Test] + public async Task SignatureVerificationAndBlockhashReplacement_ThrowsBeforeSending() + { + var (client, handler) = Make( + """{"jsonrpc":"2.0","result":{"context":{"slot":1},"value":{"err":null,"logs":[],"unitsConsumed":0}},"id":1}"""); + var options = new SimulateTransactionOptions + { + SigVerify = true, + ReplaceRecentBlockhash = true + }; + + Func act = async () => await client.SimulateTransactionAsync([1, 2, 3], options); + + await act.Should().ThrowAsync().WithParameterName("options"); + handler.CapturedRequestBody.Should().BeNull(); + } } } diff --git a/tests/SolSharp.Rpc.Tests/Streaming/ClientWebSocketConnectionTests.cs b/tests/SolSharp.Rpc.Tests/Streaming/ClientWebSocketConnectionTests.cs index a3e0f70..18d34c0 100644 --- a/tests/SolSharp.Rpc.Tests/Streaming/ClientWebSocketConnectionTests.cs +++ b/tests/SolSharp.Rpc.Tests/Streaming/ClientWebSocketConnectionTests.cs @@ -1,5 +1,6 @@ using System.Net.WebSockets; using System.Text; +using System.Threading.Channels; using FluentAssertions; using NUnit.Framework; using SolSharp.Rpc.Streaming; @@ -70,7 +71,7 @@ public async Task CloseFrame_AcknowledgesCloseAndReturnsNull() PeerCloseStatus = WebSocketCloseStatus.EndpointUnavailable, PeerCloseDescription = "maintenance" }; - socket.Push([], WebSocketMessageType.Close, endOfMessage: true); + socket.Push((byte[])[], WebSocketMessageType.Close, endOfMessage: true); await using var connection = new ClientWebSocketConnection(socket, 1024, TimeSpan.FromMilliseconds(20)); // Act @@ -87,10 +88,10 @@ public async Task CloseFrame_AcknowledgesCloseAndReturnsNull() public sealed class DisposeAsync { [Test] - public async Task CloseOutputDoesNotComplete_AbortsAndDisposes() + public async Task CloseHandshakeDoesNotComplete_AbortsAndDisposes() { // Arrange - var socket = new FakeClientWebSocket { BlockCloseOutput = true }; + var socket = new FakeClientWebSocket { BlockCloseAsync = true }; var connection = new ClientWebSocketConnection(socket, 1024, TimeSpan.FromMilliseconds(20)); // Act @@ -100,11 +101,76 @@ public async Task CloseOutputDoesNotComplete_AbortsAndDisposes() socket.AbortCalled.Should().BeTrue(); socket.DisposeCalled.Should().BeTrue(); } + + [Test] + public async Task WithoutActiveReceive_UsesFullCloseHandshake() + { + // Arrange + var socket = new FakeClientWebSocket(); + var connection = new ClientWebSocketConnection(socket, 1024, TimeSpan.FromMilliseconds(20)); + + // Act + await connection.DisposeAsync(); + + // Assert + socket.CloseAsyncCalled.Should().BeTrue(); + socket.AbortCalled.Should().BeFalse(); + socket.DisposeCalled.Should().BeTrue(); + } + + [Test] + public async Task WithActiveReceive_SendsCloseAndWaitsForPeerClose() + { + // Arrange: the existing receive owns the only legal read from the WebSocket. + var socket = new FakeClientWebSocket(); + var connection = new ClientWebSocketConnection(socket, 1024, TimeSpan.FromSeconds(1)); + var receive = connection.ReceiveAsync(CancellationToken.None).AsTask(); + await socket.ReceiveStarted.Task; + + // Act + var dispose = connection.DisposeAsync().AsTask(); + await socket.CloseOutputStarted.Task; + + // Assert: CloseOutput is only the first half; disposal waits for the receive loop to + // consume the peer's close instead of disposing the socket immediately. + dispose.IsCompleted.Should().BeFalse(); + socket.CloseAsyncCalled.Should().BeFalse(); + + socket.Push((byte[])[], WebSocketMessageType.Close, endOfMessage: true); + (await receive).Should().BeNull(); + await dispose.WaitAsync(TimeSpan.FromSeconds(1)); + + socket.AbortCalled.Should().BeFalse(); + socket.DisposeCalled.Should().BeTrue(); + } + + [Test] + public async Task WithActiveReceiveAndSilentPeer_TimesOutAndAborts() + { + // Arrange + var socket = new FakeClientWebSocket(); + var connection = new ClientWebSocketConnection(socket, 1024, TimeSpan.FromMilliseconds(20)); + using var receiveCancellation = new CancellationTokenSource(); + var receive = connection.ReceiveAsync(receiveCancellation.Token).AsTask(); + await socket.ReceiveStarted.Task; + + // Act + await connection.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + + // Assert: the one handshake timeout also bounds the active-receive path. + socket.AbortCalled.Should().BeTrue(); + socket.DisposeCalled.Should().BeTrue(); + + await receiveCancellation.CancelAsync(); + var receiveFailure = async () => await receive; + await receiveFailure.Should().ThrowAsync(); + } } private sealed class FakeClientWebSocket : IClientWebSocket { - private readonly Queue<(byte[] Data, WebSocketMessageType Type, bool EndOfMessage)> _frames = new(); + private readonly Channel<(byte[] Data, WebSocketMessageType Type, bool EndOfMessage)> _frames = + Channel.CreateUnbounded<(byte[], WebSocketMessageType, bool)>(); public WebSocketState State { get; private set; } = WebSocketState.Open; @@ -122,10 +188,20 @@ private sealed class FakeClientWebSocket : IClientWebSocket public bool BlockCloseOutput { get; init; } + public bool BlockCloseAsync { get; init; } + + public bool CloseAsyncCalled { get; private set; } + public bool AbortCalled { get; private set; } public bool DisposeCalled { get; private set; } + public TaskCompletionSource ReceiveStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource CloseOutputStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task ConnectAsync(Uri uri, CancellationToken cancellationToken) => Task.CompletedTask; public ValueTask SendAsync( @@ -134,17 +210,19 @@ public ValueTask SendAsync( bool endOfMessage, CancellationToken cancellationToken) => ValueTask.CompletedTask; - public ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken) + public async ValueTask ReceiveAsync( + Memory buffer, + CancellationToken cancellationToken) { - if (_frames.Count == 0) - throw new InvalidOperationException("The connection read past the queued frames; push more frames or end the message."); - - var frame = _frames.Dequeue(); + ReceiveStarted.TrySetResult(); + var frame = await _frames.Reader.ReadAsync(cancellationToken); frame.Data.CopyTo(buffer); if (frame.Type == WebSocketMessageType.Close) - State = WebSocketState.CloseReceived; + State = State == WebSocketState.CloseSent + ? WebSocketState.Closed + : WebSocketState.CloseReceived; - return ValueTask.FromResult(new ValueWebSocketReceiveResult(frame.Data.Length, frame.Type, frame.EndOfMessage)); + return new ValueWebSocketReceiveResult(frame.Data.Length, frame.Type, frame.EndOfMessage); } public async Task CloseOutputAsync( @@ -154,9 +232,25 @@ public async Task CloseOutputAsync( { SentCloseStatus = closeStatus; SentCloseDescription = statusDescription; + CloseOutputStarted.TrySetResult(); if (BlockCloseOutput) await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); - State = WebSocketState.CloseSent; + State = State == WebSocketState.CloseReceived + ? WebSocketState.Closed + : WebSocketState.CloseSent; + } + + public async Task CloseAsync( + WebSocketCloseStatus closeStatus, + string? statusDescription, + CancellationToken cancellationToken) + { + CloseAsyncCalled = true; + SentCloseStatus = closeStatus; + SentCloseDescription = statusDescription; + if (BlockCloseAsync) + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + State = WebSocketState.Closed; } public void Abort() @@ -171,6 +265,6 @@ public void Push(string text, WebSocketMessageType type, bool endOfMessage) => Push(Encoding.UTF8.GetBytes(text), type, endOfMessage); public void Push(byte[] data, WebSocketMessageType type, bool endOfMessage) - => _frames.Enqueue((data, type, endOfMessage)); + => _frames.Writer.TryWrite((data, type, endOfMessage)); } } diff --git a/tests/SolSharp.Rpc.Tests/Streaming/FakeWebSocketConnection.cs b/tests/SolSharp.Rpc.Tests/Streaming/FakeWebSocketConnection.cs index 628da39..3850edb 100644 --- a/tests/SolSharp.Rpc.Tests/Streaming/FakeWebSocketConnection.cs +++ b/tests/SolSharp.Rpc.Tests/Streaming/FakeWebSocketConnection.cs @@ -7,28 +7,65 @@ namespace SolSharp.Rpc.Tests.Streaming; internal sealed class FakeWebSocketConnection : IWebSocketConnection { private readonly Channel _incoming = Channel.CreateUnbounded(); + private int _connectCount; + private int _disposeCount; public List Sent { get; } = []; - public int ConnectCount { get; private set; } + public int SentCount + { + get + { + lock (Sent) + return Sent.Count; + } + } + + public int ConnectCount => Volatile.Read(ref _connectCount); + + public int DisposeCount => Volatile.Read(ref _disposeCount); + + public TaskCompletionSource DisposeStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ReceiveStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public CancellationToken LastReceiveCancellationToken { get; private set; } + + public Func? ConnectBehavior { get; set; } + + public Func? SendBehavior { get; set; } + + public Func? ReceiveMessageBehavior { get; set; } + + public Func? DisposeBehavior { get; set; } public Task ConnectAsync(Uri uri, CancellationToken cancellationToken) { - ConnectCount++; - return Task.CompletedTask; + Interlocked.Increment(ref _connectCount); + return ConnectBehavior?.Invoke(cancellationToken) ?? Task.CompletedTask; } - public ValueTask SendAsync(string text, CancellationToken cancellationToken) + public async ValueTask SendAsync(string text, CancellationToken cancellationToken) { - Sent.Add(text); - return ValueTask.CompletedTask; + if (SendBehavior is not null) + await SendBehavior(text, cancellationToken); + + lock (Sent) + Sent.Add(text); } public async ValueTask ReceiveAsync(CancellationToken cancellationToken) { + LastReceiveCancellationToken = cancellationToken; + ReceiveStarted.TrySetResult(); try { - return await _incoming.Reader.ReadAsync(cancellationToken); + var message = await _incoming.Reader.ReadAsync(cancellationToken); + if (ReceiveMessageBehavior is not null) + await ReceiveMessageBehavior(message, cancellationToken); + return message; } catch (ChannelClosedException) { @@ -36,14 +73,23 @@ public ValueTask SendAsync(string text, CancellationToken cancellationToken) } } - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() { + Interlocked.Increment(ref _disposeCount); + DisposeStarted.TrySetResult(); _incoming.Writer.TryComplete(); - return ValueTask.CompletedTask; + if (DisposeBehavior is not null) + await DisposeBehavior(); } public void PushFromServer(string message) => _incoming.Writer.TryWrite(message); + public string[] SentSnapshot() + { + lock (Sent) + return [.. Sent]; + } + /// Simulates the server dropping the connection: the next returns null. public void Drop() => _incoming.Writer.TryComplete(); } diff --git a/tests/SolSharp.Rpc.Tests/Streaming/SolanaWsClientAccountEncodingTests.cs b/tests/SolSharp.Rpc.Tests/Streaming/SolanaWsClientAccountEncodingTests.cs new file mode 100644 index 0000000..ca51677 --- /dev/null +++ b/tests/SolSharp.Rpc.Tests/Streaming/SolanaWsClientAccountEncodingTests.cs @@ -0,0 +1,297 @@ +using System.Text.Json; +using System.Threading.Channels; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Constants; +using SolSharp.Core.Primitives; +using SolSharp.Rpc.Models; +using SolSharp.Rpc.Streaming; + +namespace SolSharp.Rpc.Tests.Streaming; + +public static class SolanaWsClientAccountEncodingTests +{ + private static readonly PublicKey TokenProgram = PublicKey.Parse(SolanaProgramIds.TokenProgram); + + [TestFixture] + public sealed class SubscribeAccountWithOptionsAsync + { + [TestCase(RpcAccountEncoding.Binary, "binary")] + [TestCase(RpcAccountEncoding.Base58, "base58")] + [TestCase(RpcAccountEncoding.Base64, "base64")] + [TestCase(RpcAccountEncoding.JsonParsed, "jsonParsed")] + [TestCase(RpcAccountEncoding.Base64Zstd, "base64+zstd")] + public async Task EveryEncoding_UsesExactWireName(RpcAccountEncoding encoding, string expected) + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + + // Act + var subscribe = client.SubscribeAccountWithOptionsAsync( + TokenProgram, + new AccountSubscriptionOptions + { + Encoding = encoding, + Commitment = Commitment.Finalized + }); + var request = await NextRequestAsync(fake); + + // Assert + using var document = JsonDocument.Parse(request); + var root = document.RootElement; + root.GetProperty("method").GetString().Should().Be("accountSubscribe"); + root.GetProperty("params")[1].GetProperty("encoding").GetString().Should().Be(expected); + root.GetProperty("params")[1].GetProperty("commitment").GetString().Should().Be("finalized"); + + fake.PushFromServer(Acknowledgement(root.GetProperty("id").GetInt32(), 41)); + _ = await subscribe; + } + + [Test] + public async Task Base64ZstdNotification_PreservesEncodedUnion() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeAccountWithOptionsAsync( + TokenProgram, + new AccountSubscriptionOptions { Encoding = RpcAccountEncoding.Base64Zstd }); + var request = await NextRequestAsync(fake); + fake.PushFromServer(Acknowledgement(RequestId(request), 42)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"accountNotification","params":{"subscription":42,"result":{"context":{"slot":91},"value":{"lamports":7,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","executable":false,"rentEpoch":0,"space":3,"data":["KLUv/Q==","base64+zstd"]}}}}"""); + var notification = await reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + + // Assert + notification.Context!.Slot.Should().Be(91); + var data = notification.Value!.Data.Should().BeOfType().Subject; + data.Encoding.Should().Be(RpcAccountEncoding.Base64Zstd); + data.EncodedData.Should().Be("KLUv/Q=="); + } + + [Test] + public async Task NullOptions_ThrowsArgumentNullException() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + + // Act + var act = async () => await client.SubscribeAccountWithOptionsAsync(TokenProgram, null!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Test] + public async Task MismatchedNotificationMethod_DropsCorruptGenerationWithoutDelivery() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeAccountWithOptionsAsync( + TokenProgram, + new AccountSubscriptionOptions { Encoding = RpcAccountEncoding.Base64 }); + var request = await NextRequestAsync(fake); + fake.PushFromServer(Acknowledgement(RequestId(request), 44)); + var reader = await subscribe; + + // Act: the payload is deliberately account-shaped; routing must still reject the logs method. + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"logsNotification","params":{"subscription":44,"result":{"context":{"slot":93},"value":{"lamports":9,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","executable":false,"rentEpoch":0,"space":0,"data":["","base64"]}}}}"""); + var read = async () => await reader.ReadAsync(); + + // Assert + (await read.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + } + + [Test] + public async Task NullNotificationResult_FaultsSubscriptionInsteadOfWaitingForever() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeAccountWithOptionsAsync( + TokenProgram, + new AccountSubscriptionOptions { Encoding = RpcAccountEncoding.Binary }); + var request = await NextRequestAsync(fake); + fake.PushFromServer(Acknowledgement(RequestId(request), 45)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"accountNotification","params":{"subscription":45,"result":null}}"""); + var read = async () => await reader.ReadAsync(); + + // Assert + (await read.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + } + + [Test] + public async Task NullAccountValue_FaultsOnlyAccountWhileSiblingKeepsStreaming() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var accountSubscribe = client.SubscribeAccountWithOptionsAsync( + TokenProgram, + new AccountSubscriptionOptions { Encoding = RpcAccountEncoding.Binary }); + var accountRequest = await NextRequestAsync(fake); + fake.PushFromServer(Acknowledgement(RequestId(accountRequest), 51)); + var accountReader = await accountSubscribe; + + var logsSubscribe = client.SubscribeLogsAsync(TokenProgram); + await WaitForSentCountAsync(fake, 2); + var logsRequest = fake.SentSnapshot()[1]; + fake.PushFromServer(Acknowledgement(RequestId(logsRequest), 52)); + var logsReader = await logsSubscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"accountNotification","params":{"subscription":51,"result":{"context":{"slot":94},"value":null}}}"""); + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"logsNotification","params":{"subscription":52,"result":{"context":{"slot":95},"value":{"signature":"live","err":null,"logs":[]}}}}"""); + + // Assert + var accountRead = async () => await accountReader.ReadAsync(); + (await accountRead.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + (await logsReader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))) + .Value!.Signature.Should().Be("live"); + } + } + + [TestFixture] + public sealed class SubscribeProgramWithOptionsAsync + { + [Test] + public async Task JsonParsedWithFilters_UsesExactWireAndPreservesParsedUnion() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeProgramWithOptionsAsync( + TokenProgram, + new ProgramSubscriptionOptions + { + Encoding = RpcAccountEncoding.JsonParsed, + Commitment = Commitment.Processed, + Filters = [AccountFilter.DataSize(165)] + }); + var request = await NextRequestAsync(fake); + + using var document = JsonDocument.Parse(request); + var root = document.RootElement; + root.GetProperty("method").GetString().Should().Be("programSubscribe"); + var config = root.GetProperty("params")[1]; + config.GetProperty("encoding").GetString().Should().Be("jsonParsed"); + config.GetProperty("commitment").GetString().Should().Be("processed"); + config.GetProperty("filters")[0].GetProperty("dataSize").GetInt32().Should().Be(165); + fake.PushFromServer(Acknowledgement(root.GetProperty("id").GetInt32(), 43)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"programNotification","params":{"subscription":43,"result":{"context":{"slot":92},"value":{"pubkey":"11111111111111111111111111111111","account":{"lamports":8,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","executable":false,"rentEpoch":0,"space":165,"data":{"program":"spl-token","parsed":{"type":"account"},"space":165}}}}}}"""); + var notification = await reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + + // Assert + notification.Context!.Slot.Should().Be(92); + notification.Value!.PublicKey.Should().Be(default(PublicKey)); + var data = notification.Value.Account.Data.Should().BeOfType().Subject; + data.Program.Should().Be("spl-token"); + data.Space.Should().Be(165); + data.Value.GetProperty("type").GetString().Should().Be("account"); + } + + [Test] + public async Task NullOptions_ThrowsArgumentNullException() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + + // Act + var act = async () => await client.SubscribeProgramWithOptionsAsync(TokenProgram, null!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Test] + public async Task ExplicitNullAccount_FaultsOnlyProgramWhileSiblingKeepsStreaming() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var programSubscribe = client.SubscribeProgramWithOptionsAsync( + TokenProgram, + new ProgramSubscriptionOptions { Encoding = RpcAccountEncoding.Base64 }); + var programRequest = await NextRequestAsync(fake); + fake.PushFromServer(Acknowledgement(RequestId(programRequest), 53)); + var programReader = await programSubscribe; + + var logsSubscribe = client.SubscribeLogsAsync(TokenProgram); + await WaitForSentCountAsync(fake, 2); + var logsRequest = fake.SentSnapshot()[1]; + fake.PushFromServer(Acknowledgement(RequestId(logsRequest), 54)); + var logsReader = await logsSubscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"programNotification","params":{"subscription":53,"result":{"context":{"slot":96},"value":{"pubkey":"11111111111111111111111111111111","account":null}}}}"""); + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"logsNotification","params":{"subscription":54,"result":{"context":{"slot":97},"value":{"signature":"live","err":null,"logs":[]}}}}"""); + + // Assert + var programRead = async () => await programReader.ReadAsync(); + (await programRead.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + (await logsReader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))) + .Value!.Signature.Should().Be("live"); + } + } + + private static async Task NextRequestAsync(FakeWebSocketConnection connection) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1); + while (connection.SentCount == 0 && DateTime.UtcNow < deadline) + await Task.Yield(); + + connection.SentCount.Should().BeGreaterThan(0); + return connection.SentSnapshot()[0]; + } + + private static async Task WaitForSentCountAsync(FakeWebSocketConnection connection, int count) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1); + while (connection.SentCount < count && DateTime.UtcNow < deadline) + await Task.Yield(); + + connection.SentCount.Should().BeGreaterThanOrEqualTo(count); + } + + private static int RequestId(string request) + { + using var document = JsonDocument.Parse(request); + return document.RootElement.GetProperty("id").GetInt32(); + } + + private static string Acknowledgement(int requestId, ulong subscriptionId) => + $$"""{"jsonrpc":"2.0","result":{{subscriptionId}},"id":{{requestId}}}"""; +} diff --git a/tests/SolSharp.Rpc.Tests/Streaming/SolanaWsClientTests.cs b/tests/SolSharp.Rpc.Tests/Streaming/SolanaWsClientTests.cs index f252058..b4d86f9 100644 --- a/tests/SolSharp.Rpc.Tests/Streaming/SolanaWsClientTests.cs +++ b/tests/SolSharp.Rpc.Tests/Streaming/SolanaWsClientTests.cs @@ -1,4 +1,6 @@ +using System.Collections.Concurrent; using System.Globalization; +using System.Text.Json; using System.Threading.Channels; using FluentAssertions; using NUnit.Framework; @@ -158,7 +160,7 @@ public async Task SendsSubscribe_YieldsFrozenUpdateWithStats_ThenUnsubscribes() (await move).Should().BeTrue(); subscription.Current.Slot.Should().Be(250001ul); subscription.Current.Type.Should().Be("frozen"); - subscription.Current.Timestamp.Should().Be(1750000000123L); + subscription.Current.Timestamp.Should().Be(1750000000123UL); subscription.Current.Parent.Should().BeNull(); subscription.Current.Error.Should().BeNull(); subscription.Current.Stats!.NumTransactionEntries.Should().Be(96ul); @@ -204,6 +206,76 @@ public async Task ParsesCreatedBankParent_AndDeadError() } } + [TestFixture] + public sealed class SubscribeLogsWithFilterAsync + { + [Test] + public async Task AllFilter_SendsExactPinnedUnionBranch() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + + // Act + _ = client.SubscribeLogsWithFilterAsync( + LogsSubscriptionFilter.All, + Commitment.Processed, + cancellation.Token); + + // Assert + await WaitUntil(() => fake.SentCount == 1); + fake.SentSnapshot()[0].Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"logsSubscribe","params":["all",{"commitment":"processed"}]}"""); + await cancellation.CancelAsync(); + } + + [Test] + public async Task AllWithVotesFilter_SendsExactPinnedUnionBranch() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + + // Act + _ = client.SubscribeLogsWithFilterAsync( + LogsSubscriptionFilter.AllWithVotes, + Commitment.Finalized, + cancellation.Token); + + // Assert + await WaitUntil(() => fake.SentCount == 1); + fake.SentSnapshot()[0].Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"logsSubscribe","params":["allWithVotes",{"commitment":"finalized"}]}"""); + await cancellation.CancelAsync(); + } + + [Test] + public async Task MentionsFilter_SendsExactPinnedUnionBranch() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + + // Act + _ = client.SubscribeLogsWithFilterAsync( + LogsSubscriptionFilter.Mentions(PublicKey.Parse(SolanaProgramIds.TokenProgram)), + Commitment.Confirmed, + cancellation.Token); + + // Assert + await WaitUntil(() => fake.SentCount == 1); + fake.SentSnapshot()[0].Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"logsSubscribe","params":[{"mentions":["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"]},{"commitment":"confirmed"}]}"""); + await cancellation.CancelAsync(); + } + } + [TestFixture] public sealed class SubscribeLogs { @@ -238,8 +310,220 @@ public async Task DeliversThroughChannel_ThenUnsubscribesOnCancel() message.Value.IsError.Should().BeFalse(); await cts.CancelAsync(); - await WaitUntil(() => fake.Sent.Exists(message => message.Contains("logsUnsubscribe"))); - fake.Sent.Should().Contain(message => message.Contains("\"method\":\"logsUnsubscribe\"")); + await WaitUntil(() => fake.Sent.Exists(sent => sent.Contains("logsUnsubscribe"))); + fake.Sent.Should().Contain(sent => sent.Contains("\"method\":\"logsUnsubscribe\"")); + } + + [Test] + public async Task SupportsConcurrentReadersAndUnsignedSubscriptionIds() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + var subscribe = client.SubscribeLogsAsync( + PublicKey.Parse(SolanaProgramIds.TokenProgram), cancellationToken: cancellation.Token); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), ulong.MaxValue)); + var reader = await subscribe; + var firstRead = reader.ReadAsync().AsTask(); + var secondRead = reader.ReadAsync().AsTask(); + + // Act + fake.PushFromServer(LogNotification(ulong.MaxValue, "sig-a")); + fake.PushFromServer(LogNotification(ulong.MaxValue, "sig-b")); + var notifications = await Task.WhenAll(firstRead, secondRead); + + // Assert + notifications.Select(static notification => notification.Value!.Signature) + .Should().BeEquivalentTo("sig-a", "sig-b"); + await cancellation.CancelAsync(); + await WaitUntil(() => fake.SentSnapshot().Any(static message => message.Contains("logsUnsubscribe"))); + var unsubscribe = fake.SentSnapshot().Single(static message => message.Contains("logsUnsubscribe")); + using var document = System.Text.Json.JsonDocument.Parse(unsubscribe); + document.RootElement.GetProperty("params")[0].GetUInt64().Should().Be(ulong.MaxValue); + } + } + + [TestFixture] + public sealed class SubscribeCancellation + { + [Test] + public async Task DuringPhysicalSend_DoesNotCancelSharedTransport_AndLateAckIsReleased() + { + // Arrange: keep one routed subscription alive so a caller cancelling another subscribe + // cannot hide a connection-wide transport abort. + var fake = new FakeWebSocketConnection(); + var options = new SolanaWsClientOptions { SubscriptionAckTimeout = TimeSpan.FromSeconds(2) }; + await using var client = new SolanaWsClient(() => fake, options); + await client.ConnectAsync(new Uri("wss://localhost")); + var program = PublicKey.Parse(SolanaProgramIds.TokenProgram); + + var anchorSubscribe = client.SubscribeLogsAsync(program); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 10)); + var anchor = await anchorSubscribe; + + var physicalSendEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releasePhysicalSend = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var physicalSendToken = CancellationToken.None; + fake.SendBehavior = async (message, cancellationToken) => + { + if (!message.Contains("logsSubscribe")) + return; + + physicalSendToken = cancellationToken; + physicalSendEntered.TrySetResult(); + await releasePhysicalSend.Task; + }; + + using var cancellation = new CancellationTokenSource(); + var cancelledSubscribe = client.SubscribeLogsAsync(program, cancellationToken: cancellation.Token); + await physicalSendEntered.Task; + + try + { + // Act + await cancellation.CancelAsync(); + + // Assert: the API caller stops promptly, but its token never reaches the one shared + // physical send and the existing route remains alive. + var cancelled = async () => + await cancelledSubscribe.WaitAsync(TimeSpan.FromSeconds(1)); + var thrown = await cancelled.Should().ThrowAsync(); + thrown.Which.CancellationToken.Should().Be(cancellation.Token); + physicalSendToken.IsCancellationRequested.Should().BeFalse(); + anchor.Completion.IsCompleted.Should().BeFalse(); + } + finally + { + releasePhysicalSend.TrySetResult(); + } + + await WaitUntil(() => fake.SentSnapshot().Count(message => message.Contains("logsSubscribe")) == 2); + var cancelledRequest = fake.SentSnapshot().Last(message => message.Contains("logsSubscribe")); + fake.PushFromServer(Acknowledgement(RequestId(cancelledRequest), subscriptionId: 20)); + await WaitUntil(() => fake.SentSnapshot().Any(message => + message.Contains("logsUnsubscribe") && message.Contains("[20]"))); + + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"logsNotification","params":{"subscription":10,"result":{"context":{"slot":5},"value":{"signature":"still-live","err":null,"logs":[]}}}}"""); + (await anchor.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))) + .Value!.Signature.Should().Be("still-live"); + } + + [Test] + public async Task BeforePhysicalSend_RemovesPendingEntriesWithoutUsingTombstoneBudget() + { + // Arrange: an established subscription's unsubscribe owns the send lock while two new + // subscribe requests queue behind it and are therefore definitely not sent. + var fake = new FakeWebSocketConnection(); + var options = new SolanaWsClientOptions + { + MaxPendingSubscriptionRequests = 2, + SubscriptionAckTimeout = TimeSpan.FromSeconds(2) + }; + await using var client = new SolanaWsClient(() => fake, options); + await client.ConnectAsync(new Uri("wss://localhost")); + var program = PublicKey.Parse(SolanaProgramIds.TokenProgram); + + using var seedCancellation = new CancellationTokenSource(); + var seedSubscribe = client.SubscribeLogsAsync(program, cancellationToken: seedCancellation.Token); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 10)); + _ = await seedSubscribe; + + var unsubscribeEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseUnsubscribe = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + fake.SendBehavior = async (message, cancellationToken) => + { + if (!message.Contains("logsUnsubscribe")) + return; + + unsubscribeEntered.TrySetResult(); + await releaseUnsubscribe.Task.WaitAsync(cancellationToken); + }; + + await seedCancellation.CancelAsync(); + await unsubscribeEntered.Task; + + try + { + using var cancelA = new CancellationTokenSource(); + using var cancelB = new CancellationTokenSource(); + var subscribeA = client.SubscribeLogsAsync(program, cancellationToken: cancelA.Token); + var subscribeB = client.SubscribeLogsAsync(program, cancellationToken: cancelB.Token); + await WaitUntil(() => client.RetainedPendingSubscriptionReferenceCount == 2); + + // Act + await cancelA.CancelAsync(); + await cancelB.CancelAsync(); + + // Assert + var cancelledA = async () => await subscribeA.WaitAsync(TimeSpan.FromSeconds(1)); + var cancelledB = async () => await subscribeB.WaitAsync(TimeSpan.FromSeconds(1)); + await cancelledA.Should().ThrowAsync(); + await cancelledB.Should().ThrowAsync(); + client.RetainedPendingSubscriptionReferenceCount.Should().Be(0); + client.RetainedAcknowledgementTombstoneCount.Should().Be(0); + fake.SentSnapshot().Count(message => message.Contains("logsSubscribe")).Should().Be( + 1, + "cancelled requests queued behind the send lock never reached the transport"); + + // Freed pre-send entries make the cap immediately reusable. + var admitted = client.SubscribeLogsAsync(program); + releaseUnsubscribe.TrySetResult(); + await WaitUntil(() => fake.SentSnapshot().Count(message => message.Contains("logsSubscribe")) == 2); + var admittedRequest = fake.SentSnapshot().Last(message => message.Contains("logsSubscribe")); + fake.PushFromServer(Acknowledgement(RequestId(admittedRequest), subscriptionId: 30)); + _ = await admitted; + } + finally + { + releaseUnsubscribe.TrySetResult(); + } + } + + [Test] + public async Task LateCleanupAck_DoesNotTurnCancellationIntoSuccessfulSubscribe() + { + // Arrange: hold async continuations off-thread so cancellation wins first, the receive + // loop processes a late ACK for cleanup, and only then the subscribe continuation runs. + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + var queuedContext = new QueuedSynchronizationContext(); + var previousContext = SynchronizationContext.Current; + Task subscribe; + + try + { + SynchronizationContext.SetSynchronizationContext(queuedContext); + subscribe = client.SubscribeLogsAsync( + PublicKey.Parse(SolanaProgramIds.TokenProgram), + cancellationToken: cancellation.Token); + fake.SentCount.Should().Be(1, "the in-memory send completes synchronously"); + cancellation.Cancel(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previousContext); + } + + var request = fake.SentSnapshot().Single(message => message.Contains("logsSubscribe")); + + // Act: route the cleanup ACK while EstablishAsync's cancellation continuation is queued. + fake.PushFromServer(Acknowledgement(RequestId(request), subscriptionId: 40)); + await WaitUntil(() => fake.SentSnapshot().Any(message => + message.Contains("logsUnsubscribe") && message.Contains("[40]"))); + queuedContext.Drain(); + + // Assert + var cancelled = async () => await subscribe.WaitAsync(TimeSpan.FromSeconds(1)); + var thrown = await cancelled.Should().ThrowAsync(); + thrown.Which.CancellationToken.Should().Be(cancellation.Token); } } @@ -299,6 +583,31 @@ public async Task ErrorResponse_DoesNotDisturbOtherSubscriptions() var message = await reader.ReadAsync(); message.Value!.Signature.Should().Be("sig1"); } + + [TestCase("{\"jsonrpc\":\"2.0\",\"error\":{},\"id\":1}")] + [TestCase("{\"jsonrpc\":\"2.0\",\"error\":\"nope\",\"id\":1}")] + [TestCase("{\"jsonrpc\":\"2.0\",\"result\":7,\"error\":{\"code\":-1,\"message\":\"nope\"},\"id\":1}")] + [TestCase("{\"jsonrpc\":\"2.0\",\"id\":1}")] + [TestCase("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":\"1\"}")] + [TestCase("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1.5}")] + [TestCase("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":2147483648}")] + public async Task MalformedResponse_FaultsTheSubscribeCall(string response) + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeLogsAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); + await WaitUntil(() => fake.Sent.Count > 0); + + // Act + fake.PushFromServer(response); + var act = async () => await subscribe; + + // Assert + var exception = await act.Should().ThrowAsync(); + exception.Which.InnerException.Should().BeOfType(); + } } [TestFixture] @@ -346,6 +655,28 @@ public async Task FaultsOnlyThatSubscription_OthersKeepStreaming() [TestFixture] public sealed class SubscribeAccount { + [Test] + public async Task NullAccountValue_FaultsSubscription() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeAccountAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 6)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"accountNotification","params":{"subscription":6,"result":{"context":{"slot":100},"value":null}}}"""); + var read = async () => await reader.ReadAsync(); + + // Assert + (await read.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + } + [Test] public async Task DeliversDecodedAccount_ThenUnsubscribesOnCancel() { @@ -386,6 +717,46 @@ public async Task DeliversDecodedAccount_ThenUnsubscribesOnCancel() [TestFixture] public sealed class Reconnect { + [Test] + public async Task UnownedOperationCanceledException_RetriesNextCandidate() + { + // Arrange + var first = new FakeWebSocketConnection(); + var cancelled = new FakeWebSocketConnection + { + ConnectBehavior = _ => Task.FromException(new OperationCanceledException()) + }; + var recovered = new FakeWebSocketConnection(); + var connections = new[] { first, cancelled, recovered }; + var index = -1; + var options = new SolanaWsClientOptions + { + MaxReconnectAttempts = 2, + ReconnectInitialDelay = TimeSpan.FromMilliseconds(1), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(1) + }; + await using var client = new SolanaWsClient( + () => connections[Interlocked.Increment(ref index)], options); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeLogsAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); + await WaitUntil(() => first.SentCount == 1); + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[0]), subscriptionId: 11)); + var reader = await subscribe; + + // Act: the transport-generated OCE carries no owned cancellation token, so it is a failed + // attempt rather than a request to abandon the entire reconnect policy. + first.Drop(); + await WaitUntil(() => recovered.SentCount == 1); + var replay = recovered.SentSnapshot()[0]; + recovered.PushFromServer(Acknowledgement(RequestId(replay), subscriptionId: 12)); + recovered.PushFromServer(LogNotification(subscription: 12, signature: "recovered")); + + // Assert + (await reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))) + .Value!.Signature.Should().Be("recovered"); + cancelled.DisposeCount.Should().Be(1); + } + [Test] public async Task ReplaysSubscriptions_OntoNewConnection_AfterDrop() { @@ -427,6 +798,54 @@ public async Task ReplaysSubscriptions_OntoNewConnection_AfterDrop() (await reader.ReadAsync()).Value!.Lamports.Should().Be(2); } + [Test] + public async Task DuplicateServerSubscriptionId_FaultsGenerationAndReplaysExistingRoute() + { + // Arrange + var first = new FakeWebSocketConnection(); + var second = new FakeWebSocketConnection(); + var connections = new[] { first, second }; + var index = -1; + var options = new SolanaWsClientOptions + { + ReconnectInitialDelay = TimeSpan.FromMilliseconds(1), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(1) + }; + await using var client = new SolanaWsClient( + () => connections[Interlocked.Increment(ref index)], options); + await client.ConnectAsync(new Uri("wss://localhost")); + var program = PublicKey.Parse(SolanaProgramIds.TokenProgram); + + var firstSubscribe = client.SubscribeLogsAsync(program); + await WaitUntil(() => first.SentCount == 1); + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[0]), subscriptionId: 41)); + var reader = await firstSubscribe; + + var collidingSubscribe = client.SubscribeLogsAsync(program); + await WaitUntil(() => first.SentCount == 2); + + // Act: assigning the live route's id to another request makes notification and + // unsubscribe routing ambiguous, so the entire physical generation is rejected. + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[1]), subscriptionId: 41)); + + // Assert: the colliding initial request faults, no unsubscribe is sent for the ambiguous + // id, and the pre-existing subscription is safely replayed onto a clean connection. + var collision = async () => await collidingSubscribe.WaitAsync(TimeSpan.FromSeconds(1)); + (await collision.Should().ThrowAsync()) + .Which.Message.Should().Contain("duplicate WebSocket subscription id 41"); + first.SentSnapshot().Should().NotContain(message => + message.Contains("Unsubscribe") && message.Contains("[41]")); + + await WaitUntil(() => second.SentSnapshot().Any(message => message.Contains("logsSubscribe"))); + var replay = second.SentSnapshot().Single(message => message.Contains("logsSubscribe")); + second.PushFromServer(Acknowledgement(RequestId(replay), subscriptionId: 42)); + second.PushFromServer( + """{"jsonrpc":"2.0","method":"logsNotification","params":{"subscription":42,"result":{"context":{"slot":6},"value":{"signature":"replayed","err":null,"logs":[]}}}}"""); + + (await reader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))) + .Value!.Signature.Should().Be("replayed"); + } + [Test] public async Task CancelledDuringReplay_UnsubscribesWhenTheAckLands() { @@ -464,89 +883,607 @@ public async Task CancelledDuringReplay_UnsubscribesWhenTheAckLands() await WaitUntil(() => second.Sent.Exists(message => message.Contains("\"method\":\"accountUnsubscribe\""))); second.Sent.Last(message => message.Contains("accountUnsubscribe")).Should().Contain("[22]"); } - } - [TestFixture] - public sealed class ReconnectGiveUp - { [Test] - public async Task ExhaustedAttempts_CompleteSubscriptionsWithTheError() + public async Task CancellingOneReplay_DoesNotStopFollowingSubscriptions() { - // Arrange: the first connection works; every reconnect attempt fails. + // Arrange: A and B are both active before the connection drops. Replay is deliberately + // held on A so its consumer can cancel while B is still queued behind it. var first = new FakeWebSocketConnection(); - var attempts = 0; + var second = new FakeWebSocketConnection(); + var connections = new[] { first, second }; + var index = -1; var options = new SolanaWsClientOptions { - MaxReconnectAttempts = 2, + SubscriptionAckTimeout = TimeSpan.FromSeconds(2), ReconnectInitialDelay = TimeSpan.FromMilliseconds(1), ReconnectMaxDelay = TimeSpan.FromMilliseconds(1) }; - await using var client = new SolanaWsClient( - () => Interlocked.Increment(ref attempts) == 1 - ? first - : throw new InvalidOperationException("connection refused"), - options); + () => connections[Interlocked.Increment(ref index)], options); await client.ConnectAsync(new Uri("wss://localhost")); - var subscribe = client.SubscribeLogsAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); - await WaitUntil(() => first.Sent.Count > 0); - first.PushFromServer("""{"jsonrpc":"2.0","result":1,"id":1}"""); - var reader = await subscribe; + var accountA = PublicKey.Parse(SolanaProgramIds.TokenProgram); + var accountB = PublicKey.Parse("11111111111111111111111111111111"); + using var cancelA = new CancellationTokenSource(); + var subscribeA = client.SubscribeAccountAsync(accountA, cancellationToken: cancelA.Token); + await WaitUntil(() => first.SentCount == 1); + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[0]), subscriptionId: 11)); + var readerA = await subscribeA; + + var subscribeB = client.SubscribeAccountAsync(accountB); + await WaitUntil(() => first.SentCount == 2); + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[1]), subscriptionId: 12)); + var readerB = await subscribeB; - // Act: drop the connection; both reconnect attempts fail, so the client gives up. first.Drop(); - await WaitUntil(() => reader.Completion.IsCompleted); + await WaitUntil(() => second.SentCount == 1); + var replayA = second.SentSnapshot()[0]; + + // Act: cancel A while its replay ACK is pending. B must still be replayed. + await cancelA.CancelAsync(); + await WaitUntil(() => second.SentSnapshot().Count(message => message.Contains("accountSubscribe")) == 2); + var replayB = second.SentSnapshot().Last(message => message.Contains("accountSubscribe")); + second.PushFromServer(Acknowledgement(RequestId(replayB), subscriptionId: 22)); + second.PushFromServer(AccountNotification(subscription: 22, lamports: 222)); + + // The late ACK for cancelled A remains releasable without resurrecting its route. + second.PushFromServer(Acknowledgement(RequestId(replayA), subscriptionId: 21)); // Assert - reader.Completion.IsFaulted.Should().BeTrue(); - attempts.Should().Be(3); // the initial connect plus the two failed reconnects + (await readerB.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))) + .Value!.Lamports.Should().Be(222); + var cancelled = async () => await readerA.Completion; + await cancelled.Should().ThrowAsync(); + await WaitUntil(() => second.SentSnapshot().Any(message => + message.Contains("accountUnsubscribe") && message.Contains("[21]"))); + second.SentSnapshot().Should().Contain(message => + message.Contains("accountUnsubscribe") && message.Contains("[21]")); } - } - [TestFixture] - public sealed class SubscribeProgram - { [Test] - public async Task DeliversProgramAccount_ThenUnsubscribesOnCancel() + public async Task ReplayTimeout_FaultsOnlyThatSubscription_AndContinues() { // Arrange - var fake = new FakeWebSocketConnection(); - await using var client = new SolanaWsClient(fake); + var first = new FakeWebSocketConnection(); + var second = new FakeWebSocketConnection(); + var connections = new[] { first, second }; + var index = -1; + var options = new SolanaWsClientOptions + { + SubscriptionAckTimeout = TimeSpan.FromMilliseconds(30), + ReconnectInitialDelay = TimeSpan.FromMilliseconds(1), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(1) + }; + await using var client = new SolanaWsClient( + () => connections[Interlocked.Increment(ref index)], options); await client.ConnectAsync(new Uri("wss://localhost")); - var program = PublicKey.Parse(SolanaProgramIds.TokenProgram); - using var cts = new CancellationTokenSource(); - // Act - var subscribe = client.SubscribeProgramAsync(program, filters: [AccountFilter.DataSize(165)], cancellationToken: cts.Token); - - // Assert - await WaitUntil(() => fake.Sent.Count > 0); - fake.Sent[0].Should().Contain("\"method\":\"programSubscribe\""); - fake.Sent[0].Should().Contain("\"base64\""); - fake.Sent[0].Should().Contain(SolanaProgramIds.TokenProgram); - fake.Sent[0].Should().Contain("\"dataSize\":165"); + var accountA = PublicKey.Parse(SolanaProgramIds.TokenProgram); + var accountB = PublicKey.Parse("11111111111111111111111111111111"); + var subscribeA = client.SubscribeAccountAsync(accountA); + await WaitUntil(() => first.SentCount == 1); + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[0]), subscriptionId: 11)); + var readerA = await subscribeA; + var subscribeB = client.SubscribeAccountAsync(accountB); + await WaitUntil(() => first.SentCount == 2); + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[1]), subscriptionId: 12)); + var readerB = await subscribeB; - fake.PushFromServer("""{"jsonrpc":"2.0","result":9,"id":1}"""); - var reader = await subscribe; + first.Drop(); + await WaitUntil(() => second.SentCount == 1); + var replayA = second.SentSnapshot()[0]; - fake.PushFromServer(ProgramNotification(subscription: 9, lamports: 7)); + // Act: A never receives its replay ACK. After A times out, replay must advance to B. + await WaitUntil(() => second.SentSnapshot().Count(message => message.Contains("accountSubscribe")) == 2); + var replayB = second.SentSnapshot().Last(message => message.Contains("accountSubscribe")); + second.PushFromServer(Acknowledgement(RequestId(replayB), subscriptionId: 22)); + second.PushFromServer(AccountNotification(subscription: 22, lamports: 222)); - var message = await reader.ReadAsync(); - message.Value!.PublicKey.Should().Be(PublicKey.Parse("11111111111111111111111111111111")); - message.Value.Account.Lamports.Should().Be(7); + // A late success is still explicitly released. + second.PushFromServer(Acknowledgement(RequestId(replayA), subscriptionId: 21)); - await cts.CancelAsync(); - await WaitUntil(() => fake.Sent.Exists(entry => entry.Contains("programUnsubscribe"))); - fake.Sent.Should().Contain(entry => entry.Contains("\"method\":\"programUnsubscribe\"")); + // Assert + var readA = async () => await readerA.ReadAsync(); + (await readA.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + (await readerB.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))) + .Value!.Lamports.Should().Be(222); + await WaitUntil(() => second.SentSnapshot().Any(message => + message.Contains("accountUnsubscribe") && message.Contains("[21]"))); + second.SentSnapshot().Should().Contain(message => + message.Contains("accountUnsubscribe") && message.Contains("[21]")); } - } - [TestFixture] - public sealed class SubscribeSignature - { [Test] - public async Task DeliversNotification_ThenUnsubscribesOnCancel() + public async Task ReplayRejection_FaultsOnlyThatSubscription_AndContinues() + { + // Arrange + var first = new FakeWebSocketConnection(); + var second = new FakeWebSocketConnection(); + var connections = new[] { first, second }; + var index = -1; + var options = new SolanaWsClientOptions + { + ReconnectInitialDelay = TimeSpan.FromMilliseconds(1), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(1) + }; + await using var client = new SolanaWsClient( + () => connections[Interlocked.Increment(ref index)], options); + await client.ConnectAsync(new Uri("wss://localhost")); + + var accountA = PublicKey.Parse(SolanaProgramIds.TokenProgram); + var accountB = PublicKey.Parse("11111111111111111111111111111111"); + var subscribeA = client.SubscribeAccountAsync(accountA); + await WaitUntil(() => first.SentCount == 1); + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[0]), subscriptionId: 11)); + var readerA = await subscribeA; + var subscribeB = client.SubscribeAccountAsync(accountB); + await WaitUntil(() => first.SentCount == 2); + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[1]), subscriptionId: 12)); + var readerB = await subscribeB; + + first.Drop(); + await WaitUntil(() => second.SentCount == 1); + var replayARequestId = RequestId(second.SentSnapshot()[0]); + + // Act: the server rejects A's replay, then B is replayed and accepted normally. + second.PushFromServer( + $$"""{"jsonrpc":"2.0","error":{"code":-32000,"message":"replay rejected"},"id":{{replayARequestId}}}"""); + await WaitUntil(() => second.SentSnapshot().Count(message => message.Contains("accountSubscribe")) == 2); + var replayB = second.SentSnapshot().Last(message => message.Contains("accountSubscribe")); + second.PushFromServer(Acknowledgement(RequestId(replayB), subscriptionId: 22)); + second.PushFromServer(AccountNotification(subscription: 22, lamports: 222)); + + // Assert + var readA = async () => await readerA.ReadAsync(); + var closed = await readA.Should().ThrowAsync(); + closed.Which.InnerException.Should().BeOfType() + .Which.Message.Should().Contain("replay rejected"); + (await readerB.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))) + .Value!.Lamports.Should().Be(222); + } + + [Test] + public async Task Dispose_AbortsAndDisposesAReconnectCandidate() + { + // Arrange: the reconnect transport ignores cancellation and only returns when disposed. + var first = new FakeWebSocketConnection(); + var second = new FakeWebSocketConnection(); + second.ConnectBehavior = _ => second.DisposeStarted.Task; + var index = -1; + var options = new SolanaWsClientOptions + { + ReconnectInitialDelay = TimeSpan.FromMilliseconds(1), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(1) + }; + var client = new SolanaWsClient( + () => Interlocked.Increment(ref index) == 0 ? first : second, + options); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeLogsAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); + await WaitUntil(() => first.SentCount == 1); + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[0]), subscriptionId: 8)); + var reader = await subscribe; + first.Drop(); + await WaitUntil(() => second.ConnectCount == 1); + + // Act + await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + + // Assert + second.DisposeCount.Should().Be(1); + reader.Completion.IsCompletedSuccessfully.Should().BeTrue( + "Dispose must win over reconnect failure and complete active channels gracefully"); + } + + [Test] + public async Task SecondDrop_DoesNotLetStaleReplayClearNewGenerationRoutes() + { + // Arrange + var first = new FakeWebSocketConnection(); + var second = new FakeWebSocketConnection(); + var third = new FakeWebSocketConnection(); + var connections = new[] { first, second, third }; + var index = -1; + var options = new SolanaWsClientOptions + { + ReconnectInitialDelay = TimeSpan.FromMilliseconds(1), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(1) + }; + await using var client = new SolanaWsClient( + () => connections[Interlocked.Increment(ref index)], options); + await client.ConnectAsync(new Uri("wss://localhost")); + + var accountA = PublicKey.Parse(SolanaProgramIds.TokenProgram); + var accountB = PublicKey.Parse("11111111111111111111111111111111"); + var subscribeA = client.SubscribeAccountAsync(accountA); + await WaitUntil(() => first.SentCount == 1); + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[0]), subscriptionId: 11)); + var readerA = await subscribeA; + + var subscribeB = client.SubscribeAccountAsync(accountB); + await WaitUntil(() => first.SentCount == 2); + first.PushFromServer(Acknowledgement(RequestId(first.SentSnapshot()[1]), subscriptionId: 12)); + var readerB = await subscribeB; + + // Generation two starts replaying A, but drops before its ACK. Its replay must be joined and + // generation-scoped before generation three publishes routes using the same server IDs. + first.Drop(); + await WaitUntil(() => second.SentCount == 1); + second.Drop(); + await WaitUntil(() => third.SentCount == 1); + + // Act: acknowledge both third-generation replay requests, deliberately reusing ids 11 and 12. + AcknowledgeAccountRequest(third, third.SentSnapshot()[0], accountA, serverIdA: 11, serverIdB: 12); + await WaitUntil(() => third.SentCount == 2); + AcknowledgeAccountRequest(third, third.SentSnapshot()[1], accountA, serverIdA: 11, serverIdB: 12); + + third.PushFromServer(AccountNotification(subscription: 11, lamports: 101)); + third.PushFromServer(AccountNotification(subscription: 12, lamports: 202)); + + // Assert + (await readerA.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))).Value!.Lamports.Should().Be(101); + (await readerB.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))).Value!.Lamports.Should().Be(202); + } + } + + [TestFixture] + public sealed class ReconnectGiveUp + { + [Test] + public async Task ExhaustedAttempts_CompleteSubscriptionsWithTheError() + { + // Arrange: the first connection works; every reconnect attempt fails. + var first = new FakeWebSocketConnection(); + var attempts = 0; + var options = new SolanaWsClientOptions + { + MaxReconnectAttempts = 2, + ReconnectInitialDelay = TimeSpan.FromMilliseconds(1), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(1) + }; + + await using var client = new SolanaWsClient( + () => Interlocked.Increment(ref attempts) == 1 + ? first + : throw new InvalidOperationException("connection refused"), + options); + await client.ConnectAsync(new Uri("wss://localhost")); + + var subscribe = client.SubscribeLogsAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); + await WaitUntil(() => first.Sent.Count > 0); + first.PushFromServer("""{"jsonrpc":"2.0","result":1,"id":1}"""); + var reader = await subscribe; + + // Act: drop the connection; both reconnect attempts fail, so the client gives up. + first.Drop(); + await WaitUntil(() => reader.Completion.IsCompleted); + + // Assert + reader.Completion.IsFaulted.Should().BeTrue(); + attempts.Should().Be(3); // the initial connect plus the two failed reconnects + } + + [Test] + public async Task FailedReconnect_DisposesTheCandidateSocket() + { + // Arrange + var first = new FakeWebSocketConnection(); + var failed = new FakeWebSocketConnection + { + ConnectBehavior = _ => Task.FromException(new InvalidOperationException("connection refused")) + }; + var index = -1; + var options = new SolanaWsClientOptions + { + MaxReconnectAttempts = 1, + ReconnectInitialDelay = TimeSpan.FromMilliseconds(1), + ReconnectMaxDelay = TimeSpan.FromMilliseconds(1) + }; + await using var client = new SolanaWsClient( + () => Interlocked.Increment(ref index) == 0 ? first : failed, + options); + await client.ConnectAsync(new Uri("wss://localhost")); + + var subscribe = client.SubscribeLogsAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); + await WaitUntil(() => first.Sent.Count > 0); + first.PushFromServer("""{"jsonrpc":"2.0","result":1,"id":1}"""); + var reader = await subscribe; + + // Act + first.Drop(); + var completion = async () => await reader.Completion.WaitAsync(TimeSpan.FromSeconds(1)); + + // Assert + await completion.Should().ThrowAsync(); + failed.DisposeCount.Should().Be(1); + } + } + + [TestFixture] + public sealed class SubscribeParsedProgramAsync + { + [Test] + public async Task ParsedProgram_DecodesNestedParsedAccountKat() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + var subscribe = client.SubscribeParsedProgramAsync( + PublicKey.Parse(SolanaProgramIds.TokenProgram), + filters: [AccountFilter.DataSize(165)], + cancellationToken: cancellation.Token); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 41)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"programNotification","params":{"subscription":41,"result":{"context":{"slot":300},"value":{"pubkey":"11111111111111111111111111111111","account":{"lamports":1,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","executable":false,"rentEpoch":0,"data":{"program":"spl-token","parsed":{"type":"account","info":{"state":"initialized"}},"space":165}}}}}}"""); + var message = await reader.ReadAsync(); + + // Assert + message.Context!.Slot.Should().Be(300); + message.Value!.PublicKey.Should().Be(PublicKey.Parse("11111111111111111111111111111111")); + message.Value.Account.Program.Should().Be("spl-token"); + message.Value.Account.Parsed!.Type.Should().Be("account"); + fake.SentSnapshot()[0].Should().Contain("\"encoding\":\"jsonParsed\""); + await cancellation.CancelAsync(); + } + + [Test] + public async Task ExplicitNullAccount_FaultsSubscription() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeParsedProgramAsync( + PublicKey.Parse(SolanaProgramIds.TokenProgram)); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 42)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"programNotification","params":{"subscription":42,"result":{"context":{"slot":301},"value":{"pubkey":"11111111111111111111111111111111","account":null}}}}"""); + var read = async () => await reader.ReadAsync(); + + // Assert + (await read.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + } + } + + [TestFixture] + public sealed class SubscribeProgram + { + [Test] + public async Task DeliversProgramAccount_ThenUnsubscribesOnCancel() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + + var program = PublicKey.Parse(SolanaProgramIds.TokenProgram); + using var cts = new CancellationTokenSource(); + // Act + var subscribe = client.SubscribeProgramAsync(program, filters: [AccountFilter.DataSize(165)], cancellationToken: cts.Token); + + // Assert + await WaitUntil(() => fake.Sent.Count > 0); + fake.Sent[0].Should().Contain("\"method\":\"programSubscribe\""); + fake.Sent[0].Should().Contain("\"base64\""); + fake.Sent[0].Should().Contain(SolanaProgramIds.TokenProgram); + fake.Sent[0].Should().Contain("\"dataSize\":165"); + + fake.PushFromServer("""{"jsonrpc":"2.0","result":9,"id":1}"""); + var reader = await subscribe; + + fake.PushFromServer(ProgramNotification(subscription: 9, lamports: 7)); + + var message = await reader.ReadAsync(); + message.Value!.PublicKey.Should().Be(PublicKey.Parse("11111111111111111111111111111111")); + message.Value.Account.Lamports.Should().Be(7); + + await cts.CancelAsync(); + await WaitUntil(() => fake.Sent.Exists(entry => entry.Contains("programUnsubscribe"))); + fake.Sent.Should().Contain(entry => entry.Contains("\"method\":\"programUnsubscribe\"")); + } + } + + [TestFixture] + public sealed class SubscribeSignatureWithOptionsAsync + { + [Test] + public async Task ExplicitFalse_SendsExactPinnedConfig() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + + // Act + _ = client.SubscribeSignatureWithOptionsAsync( + "Sig111", + new SignatureSubscriptionOptions + { + Commitment = Commitment.Processed, + EnableReceivedNotification = false + }, + cancellation.Token); + + // Assert + await WaitUntil(() => fake.SentCount == 1); + fake.SentSnapshot()[0].Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"signatureSubscribe","params":["Sig111",{"commitment":"processed","enableReceivedNotification":false}]}"""); + await cancellation.CancelAsync(); + } + + [Test] + public async Task ReceivedNotification_RemainsActiveUntilFinalObject() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + var options = new SignatureSubscriptionOptions + { + Commitment = Commitment.Confirmed, + EnableReceivedNotification = true + }; + var subscribe = client.SubscribeSignatureWithOptionsAsync("Sig111", options, cancellation.Token); + await WaitUntil(() => fake.SentCount == 1); + fake.SentSnapshot()[0].Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"signatureSubscribe","params":["Sig111",{"commitment":"confirmed","enableReceivedNotification":true}]}"""); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 43)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"signatureNotification","params":{"subscription":43,"result":{"context":{"slot":10},"value":"receivedSignature"}}}"""); + var received = await reader.ReadAsync(); + + // Assert + received.Context!.Slot.Should().Be(10); + received.Value!.Kind.Should().Be(SignatureNotificationKind.Received); + received.Value.IsReceived.Should().BeTrue(); + received.Value.IsFinal.Should().BeFalse(); + reader.Completion.IsCompleted.Should().BeFalse(); + client.RetainedCancellationRegistrationCount.Should().Be(1); + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"signatureNotification","params":{"subscription":43,"result":{"context":{"slot":11},"value":{"err":null}}}}"""); + var final = await reader.ReadAsync(); + + // Assert + final.Context!.Slot.Should().Be(11); + final.Value!.Kind.Should().Be(SignatureNotificationKind.Processed); + final.Value.IsFinal.Should().BeTrue(); + await reader.Completion.WaitAsync(TimeSpan.FromSeconds(1)); + client.RetainedCancellationRegistrationCount.Should().Be(0); + fake.SentSnapshot().Should().NotContain(message => message.Contains("signatureUnsubscribe")); + } + + [TestCase("\"unexpected\"")] + [TestCase("{}")] + [TestCase("7")] + [TestCase("null")] + public async Task MalformedUnionValue_FaultsOnlySubscription(string value) + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeSignatureWithOptionsAsync( + "Sig111", + new SignatureSubscriptionOptions { EnableReceivedNotification = true }); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 44)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"signatureNotification","params":{"subscription":44,"result":{"context":{"slot":10},"value":__VALUE__}}}""" + .Replace("__VALUE__", value)); + var read = async () => await reader.ReadAsync(); + + // Assert + (await read.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + } + + [Test] + public async Task ExplicitFalse_RejectsUnexpectedReceivedEvent() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeSignatureWithOptionsAsync( + "Sig111", + new SignatureSubscriptionOptions { EnableReceivedNotification = false }); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 46)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"signatureNotification","params":{"subscription":46,"result":{"context":{"slot":10},"value":"receivedSignature"}}}"""); + var read = async () => await reader.ReadAsync(); + + // Assert + (await read.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + } + + [Test] + public async Task ScalarResult_FaultsOnlySignatureWhileOtherSubscriptionKeepsStreaming() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var signatureSubscribe = client.SubscribeSignatureWithOptionsAsync( + "Sig111", + new SignatureSubscriptionOptions { EnableReceivedNotification = true }); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 47)); + var signatureReader = await signatureSubscribe; + + var logsSubscribe = client.SubscribeLogsAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); + await WaitUntil(() => fake.SentCount == 2); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[1]), subscriptionId: 48)); + var logsReader = await logsSubscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"signatureNotification","params":{"subscription":47,"result":7}}"""); + fake.PushFromServer(LogNotification(subscription: 48, signature: "still-live")); + + // Assert + var signatureRead = async () => await signatureReader.ReadAsync(); + (await signatureRead.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + (await logsReader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))) + .Value!.Signature.Should().Be("still-live"); + } + } + + [TestFixture] + public sealed class SubscribeSignature + { + [Test] + public async Task UnexpectedReceivedEvent_IsNotMistakenForFinalConfirmation() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeSignatureAsync("Sig111"); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 49)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"signatureNotification","params":{"subscription":49,"result":{"context":{"slot":10},"value":"receivedSignature"}}}"""); + var read = async () => await reader.ReadAsync(); + + // Assert + (await read.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + } + + [Test] + public async Task DeliversOneNotification_ThenCompletesWithoutReplayableState() { // Arrange var fake = new FakeWebSocketConnection(); @@ -564,6 +1501,7 @@ public async Task DeliversNotification_ThenUnsubscribesOnCancel() fake.PushFromServer("""{"jsonrpc":"2.0","result":3,"id":1}"""); var reader = await subscribe; + client.RetainedCancellationRegistrationCount.Should().Be(1); fake.PushFromServer( """{"jsonrpc":"2.0","method":"signatureNotification","params":{"subscription":3,"result":{"context":{"slot":100},"value":{"err":null}}}}"""); @@ -571,15 +1509,78 @@ public async Task DeliversNotification_ThenUnsubscribesOnCancel() var message = await reader.ReadAsync(); message.Value!.IsError.Should().BeFalse(); - await cts.CancelAsync(); - await WaitUntil(() => fake.Sent.Exists(entry => entry.Contains("signatureUnsubscribe"))); - fake.Sent.Should().Contain(entry => entry.Contains("\"method\":\"signatureUnsubscribe\"")); + await reader.Completion.WaitAsync(TimeSpan.FromSeconds(1)); + reader.Completion.IsCompletedSuccessfully.Should().BeTrue(); + reader.TryRead(out _).Should().BeFalse(); + client.RetainedCancellationRegistrationCount.Should().Be(0); + fake.Sent.Should().NotContain( + entry => entry.Contains("signatureUnsubscribe"), + "the Solana node automatically removes signature subscriptions after their notification"); + } + + [Test] + public async Task CancellationBeforeDequeuedNotification_WinsWithoutMixedChannelOutcome() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + var subscribe = client.SubscribeSignatureAsync("Sig111", cancellationToken: cancellation.Token); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 3)); + var reader = await subscribe; + + var notificationDequeued = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + fake.ReceiveMessageBehavior = async (message, _) => + { + if (!message.Contains("signatureNotification")) + return; + notificationDequeued.TrySetResult(); + await releaseNotification.Task; + }; + + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"signatureNotification","params":{"subscription":3,"result":{"context":{"slot":100},"value":{"err":null}}}}"""); + await notificationDequeued.Task; + + // Act + await cancellation.CancelAsync(); + releaseNotification.TrySetResult(); + + // Assert + var completion = async () => await reader.Completion.WaitAsync(TimeSpan.FromSeconds(1)); + await completion.Should().ThrowAsync(); + reader.TryRead(out _).Should().BeFalse(); + client.RetainedCancellationRegistrationCount.Should().Be(0); + await WaitUntil(() => fake.SentSnapshot().Any(message => message.Contains("signatureUnsubscribe"))); } } [TestFixture] public sealed class ConfirmSignature { + [Test] + public async Task UnexpectedReceivedEvent_DoesNotSatisfyCommitment() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var confirm = client.ConfirmSignatureAsync("Sig111"); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 50)); + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"signatureNotification","params":{"subscription":50,"result":{"context":{"slot":10},"value":"receivedSignature"}}}"""); + var act = async () => await confirm; + + // Assert + await act.Should().ThrowAsync(); + } + [Test] public async Task ReturnsResultWhenNotified() { @@ -601,6 +1602,211 @@ public async Task ReturnsResultWhenNotified() // Assert result.IsError.Should().BeFalse(); } + + [Test] + public async Task TimeoutLongerThanBclTimerMaximum_IsAcceptedAndCallerCancellationWins() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + + // Act + var act = async () => await client.ConfirmSignatureAsync( + "Sig111", timeout: TimeSpan.FromDays(60), cancellationToken: cancellation.Token); + + // Assert + await act.Should().ThrowAsync(); + } + + [Test] + public async Task FiniteTimeoutAfterAcknowledgement_UnsubscribesAndReleasesState() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + + var confirm = client.ConfirmSignatureAsync("Sig111", timeout: TimeSpan.FromSeconds(1)); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 44)); + await WaitUntil(() => client.RetainedPendingSubscriptionReferenceCount == 0); + + // Act + var act = async () => await confirm; + + // Assert + await act.Should().ThrowAsync(); + await WaitUntil(() => fake.SentSnapshot().Any( + static message => message.Contains("signatureUnsubscribe") && message.Contains("44"))); + client.RetainedCancellationRegistrationCount.Should().Be(0); + client.RetainedPendingSubscriptionReferenceCount.Should().Be(0); + client.RetainedAcknowledgementTombstoneCount.Should().Be(0); + } + + [Test] + public async Task InfiniteTimeout_WaitsForNotificationWithoutSchedulingCancellation() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + + var confirm = client.ConfirmSignatureAsync("Sig111", timeout: Timeout.InfiniteTimeSpan); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 45)); + await WaitUntil(() => client.RetainedPendingSubscriptionReferenceCount == 0 && + client.RetainedCancellationRegistrationCount == 1); + + // Act + confirm.IsCompleted.Should().BeFalse(); + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"signatureNotification","params":{"subscription":45,"result":{"context":{"slot":101},"value":{"err":null}}}}"""); + var result = await confirm; + + // Assert + result.IsError.Should().BeFalse(); + await WaitUntil(() => client.RetainedCancellationRegistrationCount == 0); + } + + [Test] + public async Task NegativeFiniteTimeout_ThrowsArgumentOutOfRange() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + + // Act + var act = async () => await client.ConfirmSignatureAsync( + "Sig111", timeout: TimeSpan.FromMilliseconds(-2)); + + // Assert + await act.Should().ThrowAsync().WithParameterName("timeout"); + } + } + + [TestFixture] + public sealed class SubscribeBlocksWithOptionsAsync + { + [Test] + public async Task AllFilter_SendsExactPinnedUnionBranch() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + + // Act + _ = client.SubscribeBlocksWithOptionsAsync( + BlockSubscriptionFilter.All, + new BlockSubscriptionOptions(), + cancellation.Token); + + // Assert + await WaitUntil(() => fake.SentCount == 1); + fake.SentSnapshot()[0].Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"blockSubscribe","params":["all",{}]}"""); + await cancellation.CancelAsync(); + } + + [Test] + public async Task ExactConfig_SendsPinnedJsonAndPreservesBlockBody() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + var options = new BlockSubscriptionOptions + { + Commitment = Commitment.Finalized, + Encoding = RpcTransactionEncoding.Base64, + TransactionDetails = RpcTransactionDetails.Accounts, + ShowRewards = true, + MaxSupportedTransactionVersion = 1 + }; + var subscribe = client.SubscribeBlocksWithOptionsAsync( + BlockSubscriptionFilter.Mentions(PublicKey.Parse(SolanaProgramIds.TokenProgram)), + options, + cancellation.Token); + await WaitUntil(() => fake.SentCount == 1); + fake.SentSnapshot()[0].Should().Be( + """{"jsonrpc":"2.0","id":1,"method":"blockSubscribe","params":[{"mentionsAccountOrProgram":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"},{"commitment":"finalized","encoding":"base64","transactionDetails":"accounts","showRewards":true,"maxSupportedTransactionVersion":1}]}"""); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 46)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"blockNotification","params":{"subscription":46,"result":{"context":{"slot":20},"value":{"slot":20,"err":null,"block":{"transactions":[{"opaque":9}],"rewards":[]}}}}}"""); + var notification = await reader.ReadAsync(); + + // Assert + notification.Value!.Block!.Value.GetProperty("transactions")[0] + .GetProperty("opaque").GetInt32().Should().Be(9); + await cancellation.CancelAsync(); + } + + [Test] + public async Task MissingMandatoryBlockFields_FaultsOnlyBlockWhileSiblingKeepsStreaming() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var blockSubscribe = client.SubscribeBlocksWithOptionsAsync( + BlockSubscriptionFilter.All, + new BlockSubscriptionOptions()); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 47)); + var blockReader = await blockSubscribe; + + var logsSubscribe = client.SubscribeLogsAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); + await WaitUntil(() => fake.SentCount == 2); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[1]), subscriptionId: 48)); + var logsReader = await logsSubscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"blockNotification","params":{"subscription":47,"result":{"context":{"slot":21},"value":{}}}}"""); + fake.PushFromServer(LogNotification(subscription: 48, signature: "live")); + + // Assert + var blockRead = async () => await blockReader.ReadAsync(); + (await blockRead.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + (await logsReader.ReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))) + .Value!.Signature.Should().Be("live"); + } + } + + [TestFixture] + public sealed class SubscribeBlocksWithMaxVersionAsync + { + [Test] + public async Task ExplicitVersionOptIn_SendsVersionOne() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + + using var cts = new CancellationTokenSource(); + + // Act + _ = client.SubscribeBlocksWithMaxVersionAsync( + maxSupportedTransactionVersion: 1, cancellationToken: cts.Token); + + // Assert + await WaitUntil(() => fake.Sent.Count > 0); + fake.Sent[0].Should().Contain("\"transactionDetails\":\"signatures\""); + fake.Sent[0].Should().Contain("\"maxSupportedTransactionVersion\":1"); + + await cts.CancelAsync(); + } } [TestFixture] @@ -663,6 +1869,32 @@ public async Task MentionsFilter_SendsAccount() } } + [TestFixture] + public sealed class SubscribeParsedBlocksWithMaxVersionAsync + { + [Test] + public async Task ExplicitVersionOptIn_SendsVersionOne() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + + using var cts = new CancellationTokenSource(); + + // Act + _ = client.SubscribeParsedBlocksWithMaxVersionAsync( + maxSupportedTransactionVersion: 1, cancellationToken: cts.Token); + + // Assert + await WaitUntil(() => fake.Sent.Count > 0); + fake.Sent[0].Should().Contain("\"encoding\":\"jsonParsed\""); + fake.Sent[0].Should().Contain("\"maxSupportedTransactionVersion\":1"); + + await cts.CancelAsync(); + } + } + [TestFixture] public sealed class SubscribeParsedBlocks { @@ -706,9 +1938,32 @@ public async Task DeliversParsedBlock_WithDecodedInstructions() """{"jsonrpc":"2.0","method":"blockNotification","params":{"subscription":9,"result":{"context":{"slot":120},"value":{"slot":120,"err":null,"block":{"blockhash":"Pblk1111111111111111111111111111111111111111","previousBlockhash":"Pprev111111111111111111111111111111111111111","parentSlot":119,"blockHeight":100,"blockTime":1700000010,"transactions":[{"transaction":{"signatures":["psig1"],"message":{"accountKeys":[{"pubkey":"3x9az88Dkbxa6tkKByxqEn7jBTJCJCD4dVvou49L24ET","signer":true,"writable":true,"source":"transaction"},{"pubkey":"11111111111111111111111111111111","signer":false,"writable":false,"source":"transaction"}],"instructions":[{"program":"system","programId":"11111111111111111111111111111111","parsed":{"type":"transfer","info":{"lamports":7}},"stackHeight":null}],"recentBlockhash":"Prbh1111111111111111111111111111111111111111"}},"meta":null,"version":"legacy"}]}}}}}"""; } - [TestFixture] - public sealed class SubscribeParsedAccount - { + [TestFixture] + public sealed class SubscribeParsedAccount + { + [Test] + public async Task NullAccountValue_FaultsSubscription() + { + // Arrange + var fake = new FakeWebSocketConnection(); + await using var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + var subscribe = client.SubscribeParsedAccountAsync( + PublicKey.Parse("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 12)); + var reader = await subscribe; + + // Act + fake.PushFromServer( + """{"jsonrpc":"2.0","method":"accountNotification","params":{"subscription":12,"result":{"context":{"slot":250},"value":null}}}"""); + var read = async () => await reader.ReadAsync(); + + // Assert + (await read.Should().ThrowAsync()) + .Which.InnerException.Should().BeOfType(); + } + [Test] public async Task DeliversDecodedTokenAccount() { @@ -769,7 +2024,8 @@ public async Task CapacityExceeded_FaultsSubscription() "{\"jsonrpc\":\"2.0\",\"method\":\"slotNotification\",\"params\":{\"subscription\":42,\"result\":{\"parent\":12,\"root\":11,\"slot\":13}}}"); await WaitUntil(() => fake.Sent.Exists(message => message.Contains("\"method\":\"slotUnsubscribe\""))); (await subscription.MoveNextAsync()).Should().BeTrue(); - var act = async () => await subscription.MoveNextAsync(); + var finalMove = subscription.MoveNextAsync().AsTask(); + var act = async () => await finalMove; // Assert await act.Should().ThrowAsync() @@ -850,6 +2106,93 @@ public void NonPositiveReceiveTimeout_ThrowsArgumentOutOfRangeException() // Assert act.Should().Throw(); } + + [Test] + public void NegativeReconnectAttempts_ThrowsArgumentOutOfRangeException() + { + // Arrange + var options = new SolanaWsClientOptions { MaxReconnectAttempts = -1 }; + + // Act + Action act = () => _ = new SolanaWsClient(options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void NegativeReconnectDelay_ThrowsArgumentOutOfRangeException() + { + // Arrange + var options = new SolanaWsClientOptions { ReconnectInitialDelay = TimeSpan.FromMilliseconds(-1) }; + + // Act + Action act = () => _ = new SolanaWsClient(options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void ZeroReconnectDelays_AreAcceptedForImmediateRetry() + { + // Arrange + var options = new SolanaWsClientOptions + { + ReconnectInitialDelay = TimeSpan.Zero, + ReconnectMaxDelay = TimeSpan.Zero + }; + + // Act + Action act = () => _ = new SolanaWsClient(options); + + // Assert + act.Should().NotThrow(); + } + + [Test] + public void ReconnectMaximumBelowInitial_ThrowsArgumentOutOfRangeException() + { + // Arrange + var options = new SolanaWsClientOptions + { + ReconnectInitialDelay = TimeSpan.FromSeconds(2), + ReconnectMaxDelay = TimeSpan.FromSeconds(1) + }; + + // Act + Action act = () => _ = new SolanaWsClient(options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void NonPositiveSubscriptionAckTimeout_ThrowsArgumentOutOfRangeException() + { + // Arrange + var options = new SolanaWsClientOptions { SubscriptionAckTimeout = TimeSpan.Zero }; + + // Act + Action act = () => _ = new SolanaWsClient(options); + + // Assert + act.Should().Throw(); + } + + [TestCase(0)] + [TestCase(-1)] + public void NonPositivePendingSubscriptionLimit_ThrowsArgumentOutOfRangeException(int limit) + { + // Arrange + var options = new SolanaWsClientOptions { MaxPendingSubscriptionRequests = limit }; + + // Act + Action act = () => _ = new SolanaWsClient(options); + + // Assert + act.Should().Throw(); + } } [TestFixture] @@ -864,7 +2207,8 @@ public async Task SecondCall_Throws() await client.ConnectAsync(new Uri("wss://localhost")); // Act & Assert - var act = () => client.ConnectAsync(new Uri("wss://localhost")); + var second = client.ConnectAsync(new Uri("wss://localhost")); + var act = async () => await second; await act.Should().ThrowAsync(); } @@ -879,11 +2223,263 @@ public async Task AfterDispose_Throws() var act = () => client.ConnectAsync(new Uri("wss://localhost")); await act.Should().ThrowAsync(); } + + [Test] + public async Task ConcurrentCalls_StartOnlyOneConnectionAndRejectTheOther() + { + // Arrange + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var fake = new FakeWebSocketConnection { ConnectBehavior = _ => gate.Task }; + await using var client = new SolanaWsClient(fake); + + // Act + var first = client.ConnectAsync(new Uri("wss://localhost")); + await WaitUntil(() => fake.ConnectCount == 1); + var second = client.ConnectAsync(new Uri("wss://localhost")); + gate.TrySetResult(); + await first; + + // Assert + var act = async () => await second; + await act.Should().ThrowAsync(); + fake.ConnectCount.Should().Be(1); + } + + [Test] + public async Task FailedInitialConnection_DisposesCreatedSocket() + { + // Arrange + var fake = new FakeWebSocketConnection + { + ConnectBehavior = _ => Task.FromException(new InvalidOperationException("connection refused")) + }; + await using var client = new SolanaWsClient(() => fake, new SolanaWsClientOptions()); + + // Act + var connect = client.ConnectAsync(new Uri("wss://localhost")); + var act = async () => await connect; + + // Assert + await act.Should().ThrowAsync(); + fake.DisposeCount.Should().Be(1); + } + + [Test] + public async Task Dispose_CancelsAndDisposesAHangingInitialConnection() + { + // Arrange + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var fake = new FakeWebSocketConnection + { + ConnectBehavior = async cancellationToken => + { + entered.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + }; + var client = new SolanaWsClient(fake); + var connect = client.ConnectAsync(new Uri("wss://localhost")); + await entered.Task; + + // Act + await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + + // Assert + var act = async () => await connect; + await act.Should().ThrowAsync(); + fake.DisposeCount.Should().Be(1); + } + + [Test] + public async Task UserCancellation_PreservesTheOriginalToken() + { + // Arrange + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var fake = new FakeWebSocketConnection + { + ConnectBehavior = async cancellationToken => + { + entered.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + }; + await using var client = new SolanaWsClient(fake); + using var cancellation = new CancellationTokenSource(); + var connect = client.ConnectAsync(new Uri("wss://localhost"), cancellation.Token); + await entered.Task; + + // Act + await cancellation.CancelAsync(); + + // Assert + var act = async () => await connect; + var thrown = await act.Should().ThrowAsync(); + thrown.Which.CancellationToken.Should().Be(cancellation.Token); + } + + [Test] + public async Task Dispose_AbortsInitialConnectionThatOnlyReactsToSocketDisposal() + { + // Arrange: this deliberately ignores the cancellation token. Disposing the candidate is the + // only action that releases ConnectAsync. + var releaseConnect = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var fake = new FakeWebSocketConnection + { + ConnectBehavior = _ => releaseConnect.Task, + DisposeBehavior = () => + { + releaseConnect.TrySetResult(); + return ValueTask.CompletedTask; + } + }; + var client = new SolanaWsClient(fake); + var connect = client.ConnectAsync(new Uri("wss://localhost")); + await WaitUntil(() => fake.ConnectCount == 1); + + // Act + await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + + // Assert + var act = async () => await connect; + await act.Should().ThrowAsync(); + fake.DisposeCount.Should().Be(1); + } + } + + [TestFixture] + public sealed class SubscribeAcknowledgementTimeout + { + [Test] + public async Task MissingAcknowledgement_FaultsSubscribeWithinConfiguredBound() + { + // Arrange + var fake = new FakeWebSocketConnection(); + var options = new SolanaWsClientOptions + { + AutoReconnect = false, + SubscriptionAckTimeout = TimeSpan.FromMilliseconds(30) + }; + await using var client = new SolanaWsClient(() => fake, options); + await client.ConnectAsync(new Uri("wss://localhost")); + + // Act + var subscribe = client.SubscribeLogsAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); + await WaitUntil(() => fake.Sent.Count > 0); + + // Assert + var act = async () => await subscribe; + await act.Should().ThrowAsync(); + } + + [Test] + public async Task LateAcknowledgement_AfterTimeout_IsImmediatelyUnsubscribed() + { + // Arrange + var fake = new FakeWebSocketConnection(); + var options = new SolanaWsClientOptions + { + AutoReconnect = false, + SubscriptionAckTimeout = TimeSpan.FromMilliseconds(20) + }; + await using var client = new SolanaWsClient(() => fake, options); + await client.ConnectAsync(new Uri("wss://localhost")); + + var subscribe = client.SubscribeLogsAsync(PublicKey.Parse(SolanaProgramIds.TokenProgram)); + await WaitUntil(() => fake.SentCount == 1); + var requestId = RequestId(fake.SentSnapshot()[0]); + var act = async () => await subscribe; + await act.Should().ThrowAsync(); + + // Act: the server accepted the request, but replied after the local timeout won. + fake.PushFromServer( + $$"""{"jsonrpc":"2.0","result":77,"error":null,"id":{{requestId}}}"""); + + // Assert + await WaitUntil(() => fake.SentSnapshot().Any(message => message.Contains("logsUnsubscribe"))); + fake.SentSnapshot() + .Should().Contain(message => message.Contains("\"method\":\"logsUnsubscribe\"") && message.Contains("[77]")); + } + + [Test] + public async Task TombstonesAreBounded_DetachSubscriptions_AndStillHandleLateAcknowledgements() + { + // Arrange: two never-ACK requests fill the deliberately tiny pending-request budget. + var fake = new FakeWebSocketConnection(); + var options = new SolanaWsClientOptions + { + AutoReconnect = false, + SubscriptionAckTimeout = TimeSpan.FromMilliseconds(20), + MaxPendingSubscriptionRequests = 2 + }; + await using var client = new SolanaWsClient(() => fake, options); + await client.ConnectAsync(new Uri("wss://localhost")); + var program = PublicKey.Parse(SolanaProgramIds.TokenProgram); + + var first = client.SubscribeLogsAsync(program); + await WaitUntil(() => fake.SentSnapshot().Count(message => message.Contains("logsSubscribe")) == 1); + var firstRequest = fake.SentSnapshot().Single(message => message.Contains("logsSubscribe")); + var firstFailure = async () => await first; + await firstFailure.Should().ThrowAsync(); + + var second = client.SubscribeLogsAsync(program); + await WaitUntil(() => fake.SentSnapshot().Count(message => message.Contains("logsSubscribe")) == 2); + var secondFailure = async () => await second; + await secondFailure.Should().ThrowAsync(); + + // Assert: the retained entries contain no Subscription graphs and never exceed the cap. + client.RetainedPendingSubscriptionReferenceCount.Should().Be(0); + client.RetainedAcknowledgementTombstoneCount.Should().Be(2); + + var rejectedAtCap = client.SubscribeLogsAsync(program); + var capFailure = async () => await rejectedAtCap; + (await capFailure.Should().ThrowAsync()) + .Which.Message.Should().Contain("maximum of 2 pending subscription requests"); + fake.SentSnapshot().Count(message => message.Contains("logsSubscribe")).Should().Be( + 2, + "the cap must be checked before another request is sent"); + + // Act: a late ACK consumes one tombstone and releases the server-side subscription. + fake.PushFromServer(Acknowledgement(RequestId(firstRequest), subscriptionId: 77)); + await WaitUntil(() => fake.SentSnapshot().Any(message => + message.Contains("logsUnsubscribe") && message.Contains("[77]"))); + client.RetainedAcknowledgementTombstoneCount.Should().Be(1); + + // The released budget is immediately reusable by a fresh subscription request. + var admitted = client.SubscribeLogsAsync(program); + await WaitUntil(() => fake.SentSnapshot().Count(message => message.Contains("logsSubscribe")) == 3); + var admittedRequest = fake.SentSnapshot().Last(message => message.Contains("logsSubscribe")); + fake.PushFromServer(Acknowledgement(RequestId(admittedRequest), subscriptionId: 88)); + _ = await admitted; + } } [TestFixture] public sealed class Dispose { + [Test] + public async Task DisposesConnectionBeforeCancellingEpochReceiveToken() + { + // Arrange + var fake = new FakeWebSocketConnection(); + var receiveTokenWasCancelledAtDispose = true; + fake.DisposeBehavior = () => + { + receiveTokenWasCancelledAtDispose = fake.LastReceiveCancellationToken.IsCancellationRequested; + return ValueTask.CompletedTask; + }; + var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + await fake.ReceiveStarted.Task; + + // Act + await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); + + // Assert: the adapter needs the epoch receive alive while its DisposeAsync performs the + // close handshake; the epoch token is cancelled immediately after transport disposal. + receiveTokenWasCancelledAtDispose.Should().BeFalse(); + fake.LastReceiveCancellationToken.IsCancellationRequested.Should().BeTrue(); + } + [Test] public async Task CompletesActiveSubscriptionChannels() { @@ -936,15 +2532,127 @@ public async Task CanBeCalledTwice() var act = async () => await client.DisposeAsync(); await act.Should().NotThrowAsync(); } + + [Test] + public async Task ConcurrentCalls_WaitForTheSameCleanup() + { + // Arrange + var releaseDispose = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var fake = new FakeWebSocketConnection + { + DisposeBehavior = async () => await releaseDispose.Task + }; + var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + + // Act + var first = client.DisposeAsync().AsTask(); + await fake.DisposeStarted.Task; + var second = client.DisposeAsync().AsTask(); + + // Assert: idempotence means sharing the cleanup, not returning before it finishes. + first.IsCompleted.Should().BeFalse(); + second.IsCompleted.Should().BeFalse(); + releaseDispose.TrySetResult(); + await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(1)); + fake.DisposeCount.Should().Be(1); + } + + [Test] + public async Task WaitsForQueuedUnsubscribeBeforeDisposingSendState() + { + // Arrange + var fake = new FakeWebSocketConnection(); + var client = new SolanaWsClient(fake); + await client.ConnectAsync(new Uri("wss://localhost")); + using var cancellation = new CancellationTokenSource(); + var subscribe = client.SubscribeLogsAsync( + PublicKey.Parse(SolanaProgramIds.TokenProgram), cancellationToken: cancellation.Token); + await WaitUntil(() => fake.SentCount == 1); + fake.PushFromServer(Acknowledgement(RequestId(fake.SentSnapshot()[0]), subscriptionId: 9)); + _ = await subscribe; + + var unsubscribeEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseUnsubscribe = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + fake.SendBehavior = async (message, _) => + { + if (!message.Contains("logsUnsubscribe")) + return; + unsubscribeEntered.TrySetResult(); + await releaseUnsubscribe.Task; + }; + + await cancellation.CancelAsync(); + await unsubscribeEntered.Task; + + // Act + var dispose = client.DisposeAsync().AsTask(); + + // Assert + dispose.IsCompleted.Should().BeFalse(); + releaseUnsubscribe.TrySetResult(); + await dispose.WaitAsync(TimeSpan.FromSeconds(1)); + fake.DisposeCount.Should().Be(1); + } + } + + private sealed class QueuedSynchronizationContext : SynchronizationContext + { + private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> _callbacks = new(); + + public override void Post(SendOrPostCallback d, object? state) => _callbacks.Enqueue((d, state)); + + public void Drain() + { + var previousContext = Current; + SetSynchronizationContext(this); + try + { + while (_callbacks.TryDequeue(out var callback)) + callback.Callback(callback.State); + } + finally + { + SetSynchronizationContext(previousContext); + } + } + } + + private static int RequestId(string request) + { + using var document = System.Text.Json.JsonDocument.Parse(request); + return document.RootElement.GetProperty("id").GetInt32(); + } + + private static string Acknowledgement(int requestId, ulong subscriptionId) => + $$"""{"jsonrpc":"2.0","result":{{subscriptionId}},"id":{{requestId}}}"""; + + private static void AcknowledgeAccountRequest( + FakeWebSocketConnection connection, + string request, + PublicKey accountA, + ulong serverIdA, + ulong serverIdB) + { + using var document = System.Text.Json.JsonDocument.Parse(request); + var root = document.RootElement; + var subscribedAccount = root.GetProperty("params")[0].GetString(); + var serverId = subscribedAccount == accountA.ToString() ? serverIdA : serverIdB; + connection.PushFromServer(Acknowledgement(root.GetProperty("id").GetInt32(), serverId)); } // A plain (non-interpolated) raw string so the four trailing literal braces stay content; the two // values are substituted afterwards (an interpolated raw string cannot mix {{ }} holes with }}}} here). - private static string AccountNotification(long subscription, ulong lamports) => + private static string AccountNotification(ulong subscription, ulong lamports) => """{"jsonrpc":"2.0","method":"accountNotification","params":{"subscription":__SUB__,"result":{"context":{"slot":1},"value":{"data":["","base64"],"executable":false,"lamports":__LAMP__,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":0,"space":0}}}}""" .Replace("__SUB__", subscription.ToString(CultureInfo.InvariantCulture)) .Replace("__LAMP__", lamports.ToString(CultureInfo.InvariantCulture)); + private static string LogNotification(ulong subscription, string signature) => + """{"jsonrpc":"2.0","method":"logsNotification","params":{"subscription":__SUB__,"result":{"context":{"slot":1},"value":{"signature":"__SIG__","err":null,"logs":[]}}}}""" + .Replace("__SUB__", subscription.ToString(CultureInfo.InvariantCulture)) + .Replace("__SIG__", signature, StringComparison.Ordinal); + private static string ProgramNotification(long subscription, ulong lamports) => """{"jsonrpc":"2.0","method":"programNotification","params":{"subscription":__SUB__,"result":{"context":{"slot":1},"value":{"pubkey":"11111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":__LAMP__,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":0,"space":3}}}}}""" .Replace("__SUB__", subscription.ToString(CultureInfo.InvariantCulture)) @@ -952,7 +2660,13 @@ private static string ProgramNotification(long subscription, ulong lamports) => private static async Task WaitUntil(Func condition) { - for (var attempt = 0; attempt < 100 && !condition(); attempt++) + for (var attempt = 0; attempt < 100; attempt++) + { + if (condition()) + return; await Task.Delay(10); + } + + Assert.Fail("Condition was not met within one second."); } } diff --git a/tests/SolSharp.Rpc.Tests/Token2022ExtensionsTests.cs b/tests/SolSharp.Rpc.Tests/Token2022ExtensionsTests.cs index 9a5808a..12956bc 100644 --- a/tests/SolSharp.Rpc.Tests/Token2022ExtensionsTests.cs +++ b/tests/SolSharp.Rpc.Tests/Token2022ExtensionsTests.cs @@ -76,11 +76,11 @@ public void TransferFeeAndPointerAndCloseAuthority_DecodeTyped() // Assert extensions.Should().NotBeNull(); - extensions!.Extensions.Should().HaveCount(4); + extensions.Extensions.Should().HaveCount(4); var fee = extensions.GetTransferFeeConfig(); fee.Should().NotBeNull(); - fee!.TransferFeeConfigAuthority.Should().Be(Pk(10)); + fee.TransferFeeConfigAuthority.Should().Be(Pk(10)); fee.WithdrawWithheldAuthority.Should().Be(Pk(11)); fee.WithheldAmount.Should().Be(ulong.MaxValue); fee.OlderTransferFee.Should().Be(new TransferFee { Epoch = 1, MaximumFee = 10, BasisPoints = 100 }); @@ -90,7 +90,7 @@ public void TransferFeeAndPointerAndCloseAuthority_DecodeTyped() var metadataPointer = extensions.GetMetadataPointer(); metadataPointer.Should().NotBeNull(); - metadataPointer!.Authority.Should().Be(Pk(7)); + metadataPointer.Authority.Should().Be(Pk(7)); metadataPointer.MetadataAddress.Should().Be(Pk(8)); extensions.Has(ExtensionType.MintCloseAuthority).Should().BeTrue(); @@ -132,6 +132,68 @@ public void ZeroPadding_EndsTheWalk() extensions!.Extensions.Should().ContainSingle().Which.Type.Should().Be(ExtensionType.ImmutableOwner); } + [Test] + public void SingleTrailingReallocationByte_IsAccepted() + { + byte[] data = [.. Extended(accountType: 1), 0xAA]; + + TokenExtensionSet.DecodeMint(data).Should().NotBeNull(); + } + + [Test] + public void UninitializedType_StopsWithoutInspectingRemainingBytes() + { + byte[] data = [.. Extended(accountType: 1), 0, 0, 0xAA, 0xBB]; + + TokenExtensionSet.DecodeMint(data).Should().NotBeNull(); + } + + [Test] + public void OverlongFixedSizeExtensions_ReturnNullFromTypedViews() + { + // Arrange: StateWithExtensions returns the TLV bytes, but its typed Pod view requires + // each fixed-size extension's declared length to equal size_of::() exactly. + var data = Extended( + accountType: 1, + ((ushort)ExtensionType.TransferFeeConfig, new byte[TransferFeeConfig.Length + 1]), + ((ushort)ExtensionType.MintCloseAuthority, new byte[PublicKey.Length + 1]), + ((ushort)ExtensionType.PermanentDelegate, new byte[PublicKey.Length + 1]), + ((ushort)ExtensionType.DefaultAccountState, new byte[2]), + ((ushort)ExtensionType.MetadataPointer, new byte[MetadataPointer.Length + 1])); + + // Act + var extensions = TokenExtensionSet.DecodeMint(data)!; + + // Assert + extensions.GetTransferFeeConfig().Should().BeNull(); + extensions.GetMintCloseAuthority().Should().BeNull(); + extensions.GetPermanentDelegate().Should().BeNull(); + extensions.GetDefaultAccountState().Should().BeNull(); + extensions.GetMetadataPointer().Should().BeNull(); + } + + [TestCase((byte)3)] + [TestCase(byte.MaxValue)] + public void UndefinedDefaultAccountState_ReturnsNull(byte state) + { + var extensions = TokenExtensionSet.DecodeMint( + Extended(accountType: 1, ((ushort)ExtensionType.DefaultAccountState, [state]))); + + extensions.Should().NotBeNull(); + extensions!.GetDefaultAccountState().Should().BeNull(); + } + + [Test] + public void UnknownFutureExtensionType_IsPreservedAsOpaqueData() + { + var extensions = TokenExtensionSet.DecodeMint( + Extended(accountType: 1, (29, [0xAA, 0xBB]))); + + extensions.Should().NotBeNull(); + extensions!.Extensions.Should().ContainSingle().Which.Should().BeEquivalentTo( + new TokenExtension { Type = (ExtensionType)29, Data = [0xAA, 0xBB] }); + } + [Test] public void TokenMetadata_DecodesBorshContent() { @@ -155,7 +217,7 @@ public void TokenMetadata_DecodesBorshContent() // Assert metadata.Should().NotBeNull(); - metadata!.UpdateAuthority.Should().BeNull(); + metadata.UpdateAuthority.Should().BeNull(); metadata.Mint.Should().Be(Pk(3)); metadata.Name.Should().Be("Cool Token"); metadata.Symbol.Should().Be("COOL"); @@ -164,6 +226,29 @@ public void TokenMetadata_DecodesBorshContent() .Which.Should().Be(new KeyValuePair("kind", "meme")); } + [Test] + public void TokenMetadata_HostileAdditionalMetadataCount_ReturnsNullWithoutAllocatingFromCount() + { + // Arrange: valid fixed fields followed by an impossible Vec length and no pair data. + using var buffer = new MemoryStream(); + buffer.Write(new byte[32]); + buffer.Write(Pk(3).ToBytes()); + WriteBorshString(buffer, "name"); + WriteBorshString(buffer, "symbol"); + WriteBorshString(buffer, "uri"); + Span count = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(count, int.MaxValue); + buffer.Write(count); + + var data = Extended(accountType: 1, ((ushort)ExtensionType.TokenMetadata, buffer.ToArray())); + + // Act + var metadata = TokenExtensionSet.DecodeMint(data)!.GetTokenMetadata(); + + // Assert + metadata.Should().BeNull(); + } + private static void WriteBorshString(MemoryStream buffer, string value) { var bytes = Encoding.UTF8.GetBytes(value); @@ -195,7 +280,7 @@ public void AccountExtensions_DecodeTyped() // Assert extensions.Should().NotBeNull(); - extensions!.GetWithheldTransferFee().Should().Be(777ul); + extensions.GetWithheldTransferFee().Should().Be(777ul); extensions.GetMemoTransferRequired().Should().BeTrue(); extensions.Has(ExtensionType.ImmutableOwner).Should().BeTrue(); extensions.Find(ExtensionType.CpiGuard).Should().BeNull(); @@ -208,5 +293,31 @@ public void BareAccount_ReturnsEmptySet() [Test] public void TooShort_ReturnsNull() => TokenExtensionSet.DecodeAccount(new byte[10]).Should().BeNull(); + + [Test] + public void OverlongFixedSizeExtensions_ReturnNullFromTypedViews() + { + // Arrange + var data = Extended( + accountType: 2, + ((ushort)ExtensionType.TransferFeeAmount, new byte[sizeof(ulong) + 1]), + ((ushort)ExtensionType.MemoTransfer, new byte[2])); + + // Act + var extensions = TokenExtensionSet.DecodeAccount(data)!; + + // Assert + extensions.GetWithheldTransferFee().Should().BeNull(); + extensions.GetMemoTransferRequired().Should().BeNull(); + } + + [Test] + public void MemoTransferPodBool_NonZeroValueReturnsTrue() + { + var extensions = TokenExtensionSet.DecodeAccount( + Extended(accountType: 2, ((ushort)ExtensionType.MemoTransfer, [byte.MaxValue]))); + + extensions!.GetMemoTransferRequired().Should().BeTrue(); + } } } diff --git a/tests/SolSharp.Rpc.Tests/TransactionErrorTests.cs b/tests/SolSharp.Rpc.Tests/TransactionErrorTests.cs index 7a1122a..8ce8f2a 100644 --- a/tests/SolSharp.Rpc.Tests/TransactionErrorTests.cs +++ b/tests/SolSharp.Rpc.Tests/TransactionErrorTests.cs @@ -58,6 +58,7 @@ public void InstructionError_CustomCode() error!.InstructionIndex.Should().Be(2); error.InstructionError!.Kind.Should().Be("Custom"); error.InstructionError.CustomCode.Should().Be(6001); + error.Details!.Value[1].GetProperty("Custom").GetInt32().Should().Be(6001); error.ToString().Should().Contain("Custom(6001)"); } @@ -65,11 +66,40 @@ public void InstructionError_CustomCode() public void ObjectVariant_WithoutInstructionError() { // Act - var error = ParseJson("""{"DuplicateInstruction":3}"""); + var error = ParseJson("""{"DuplicateInstruction":42}"""); // Assert error!.Kind.Should().Be("DuplicateInstruction"); + error.DuplicateInstructionIndex.Should().Be(42); error.InstructionError.Should().BeNull(); + error.Details!.Value.GetInt32().Should().Be(42); + } + + [TestCase("InsufficientFundsForRent")] + [TestCase("ProgramExecutionTemporarilyRestricted")] + public void AccountIndexStructVariant(string kind) + { + // Act + var json = """{"__KIND__":{"account_index":42}}""" + .Replace("__KIND__", kind, StringComparison.Ordinal); + var error = ParseJson(json); + + // Assert + error!.Kind.Should().Be(kind); + error.AccountIndex.Should().Be(42); + error.Details!.Value.GetProperty("account_index").GetInt32().Should().Be(42); + error.ToString().Should().Contain("account 42"); + } + + [Test] + public void UnknownParameterizedVariant_PreservesPayload() + { + // Act + var error = ParseJson("""{"FutureParameterized":{"value":7}}"""); + + // Assert + error!.Kind.Should().Be("FutureParameterized"); + error.Details!.Value.GetProperty("value").GetInt32().Should().Be(7); } } } diff --git a/tests/SolSharp.Rpc.Tests/TransactionResponseJsonTests.cs b/tests/SolSharp.Rpc.Tests/TransactionResponseJsonTests.cs new file mode 100644 index 0000000..2aff744 --- /dev/null +++ b/tests/SolSharp.Rpc.Tests/TransactionResponseJsonTests.cs @@ -0,0 +1,770 @@ +using System.Text.Json; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Rpc.Models; +using SolSharp.Rpc.Models.Parsed; +using SolSharp.Rpc.Protocol; + +namespace SolSharp.Rpc.Tests; + +public static class RpcTransactionVersionTests +{ + [TestFixture] + public sealed class FromNumber + { + [TestCase(0)] + [TestCase(1)] + [TestCase(byte.MaxValue)] + public void U8Value_PreservesNumber(int number) + { + // Act + var version = RpcTransactionVersion.FromNumber((byte)number); + + // Assert + version.IsLegacy.Should().BeFalse(); + version.Number.Should().Be((byte)number); + } + } + + [TestFixture] + public sealed class Read + { + [Test] + public void LegacyString_ProducesLegacyVariant() + { + // Act + var version = JsonSerializer.Deserialize("\"legacy\"", RpcJson.Options); + + // Assert + version.Should().Be(RpcTransactionVersion.Legacy); + version.IsLegacy.Should().BeTrue(); + version.Number.Should().BeNull(); + } + + [TestCase("0", 0)] + [TestCase("1", 1)] + [TestCase("255", byte.MaxValue)] + public void U8Number_ProducesNumericVariant(string json, int expected) + { + // Act + var version = JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + version.Should().Be(RpcTransactionVersion.FromNumber((byte)expected)); + } + + [TestCase("null")] + [TestCase("true")] + [TestCase("{}")] + [TestCase("[]")] + [TestCase("\"Legacy\"")] + [TestCase("\"0\"")] + [TestCase("-1")] + [TestCase("256")] + [TestCase("1.0")] + public void ValueOutsideClosedUnion_ThrowsJsonException(string json) + { + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw().WithMessage("*legacy*u8 integer*"); + } + } + + [TestFixture] + public sealed class Write + { + [Test] + public void Variants_ProduceExactWireValues() + { + // Act + var legacy = JsonSerializer.Serialize(RpcTransactionVersion.Legacy, RpcJson.Options); + var numeric = JsonSerializer.Serialize(RpcTransactionVersion.FromNumber(byte.MaxValue), RpcJson.Options); + + // Assert + legacy.Should().Be("\"legacy\""); + numeric.Should().Be("255"); + } + + [Test] + public void UninitializedValue_ThrowsJsonException() + { + // Act + Action act = () => JsonSerializer.Serialize(default(RpcTransactionVersion), RpcJson.Options); + + // Assert + act.Should().Throw().WithMessage("*uninitialized transaction version*"); + } + } +} + +public static class TransactionResponseJsonTests +{ + [TestFixture] + public sealed class Deserialize + { + [TestCase("{}")] + [TestCase("{\"version\":null}")] + public void OptionalVersionAbsentOrNull_IsPreservedAsNull(string json) + { + // Arrange + using var document = JsonDocument.Parse(json); + var version = document.RootElement.TryGetProperty("version", out var member) + ? ",\"version\":" + member.GetRawText() + : string.Empty; + var responseJson = + "{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":null" + + version + "}"; + + // Act + var response = JsonSerializer.Deserialize(responseJson, RpcJson.Options); + + // Assert + response!.Version.Should().BeNull(); + } + + [TestCase("{\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":[]}")] + [TestCase("{\"err\":null,\"fee\":0,\"preBalances\":[],\"postBalances\":[]}")] + [TestCase("{\"err\":null,\"status\":{\"Ok\":null},\"preBalances\":[],\"postBalances\":[]}")] + [TestCase("{\"err\":null,\"status\":{\"Ok\":null},\"fee\":0,\"postBalances\":[]}")] + [TestCase("{\"err\":null,\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[]}")] + [TestCase("{\"err\":null,\"status\":null,\"fee\":0,\"preBalances\":[],\"postBalances\":[]}")] + [TestCase("{\"err\":null,\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":null,\"postBalances\":[]}")] + [TestCase("{\"err\":null,\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":null}")] + public void MalformedCoreMetadata_ThrowsJsonException(string metadata) + { + // Arrange + var json = + "{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":" + + metadata + "}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{\"data\":[\"\",\"base64\"]}")] + [TestCase("{\"programId\":\"11111111111111111111111111111111\"}")] + [TestCase("{\"programId\":\"11111111111111111111111111111111\",\"data\":null}")] + public void MalformedReturnData_ThrowsJsonException(string returnData) + { + // Arrange + var json = + "{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":{\"err\":null," + + "\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":[],\"returnData\":" + + returnData + "}}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{\"writable\":[]}")] + [TestCase("{\"readonly\":[]}")] + [TestCase("{\"writable\":null,\"readonly\":[]}")] + [TestCase("{\"writable\":[],\"readonly\":null}")] + public void MalformedLoadedAddresses_ThrowsJsonException(string loadedAddresses) + { + // Arrange + var json = + "{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":{\"err\":null," + + "\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":[],\"loadedAddresses\":" + + loadedAddresses + "}}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{\"lamports\":0,\"postBalance\":0}")] + [TestCase("{\"pubkey\":\"11111111111111111111111111111111\",\"postBalance\":0}")] + [TestCase("{\"pubkey\":\"11111111111111111111111111111111\",\"lamports\":0}")] + public void RewardMissingMandatoryField_ThrowsJsonException(string reward) + { + // Arrange + var json = + "{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":{\"err\":null," + + "\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":[],\"rewards\":[" + + reward + "]}}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void RewardOptionsAbsent_RemainNull() + { + // Arrange + const string json = + """{"slot":0,"blockTime":null,"transaction":["","base64"],"meta":{"err":null,"status":{"Ok":null},"fee":0,"preBalances":[],"postBalances":[],"rewards":[{"pubkey":"11111111111111111111111111111111","lamports":0,"postBalance":0}]}}"""; + + // Act + var response = JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + var reward = response!.Meta!.Rewards.Should().ContainSingle().Subject; + reward.RewardType.Should().BeNull(); + reward.Commission.Should().BeNull(); + reward.CommissionBps.Should().BeNull(); + } + + [TestCase("Fee")] + [TestCase("Rent")] + [TestCase("Staking")] + [TestCase("Voting")] + [TestCase("DeactivatedStake")] + public void PinnedRewardType_IsAccepted(string rewardType) + { + // Arrange + var json = + "{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":{\"err\":null," + + "\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":[],\"rewards\":[{" + + "\"pubkey\":\"11111111111111111111111111111111\",\"lamports\":0,\"postBalance\":0,\"rewardType\":\"" + + rewardType + "\"}]}}"; + + // Act + var response = JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + response!.Meta!.Rewards.Should().ContainSingle().Which.RewardType.Should().Be(rewardType); + } + + [Test] + public void UnknownRewardType_ThrowsJsonException() + { + // Arrange + const string json = + """{"slot":0,"blockTime":null,"transaction":["","base64"],"meta":{"err":null,"status":{"Ok":null},"fee":0,"preBalances":[],"postBalances":[],"rewards":[{"pubkey":"11111111111111111111111111111111","lamports":0,"postBalance":0,"rewardType":"Unknown"}]}}"""; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw().WithMessage("*reward type*"); + } + + [TestCase("{\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":null}")] + [TestCase("{\"slot\":0,\"transaction\":[\"\",\"base64\"],\"meta\":null}")] + [TestCase("{\"slot\":0,\"blockTime\":null,\"meta\":null}")] + [TestCase("{\"slot\":0,\"blockTime\":null,\"transaction\":null,\"meta\":null}")] + [TestCase("{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"]}")] + public void MissingMandatoryOuterField_ThrowsJsonException(string json) + { + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void ExactIntegerWidths_AcceptBoundaries() + { + // Arrange + const string json = + """{"slot":0,"blockTime":null,"transaction":["","base64"],"meta":{"err":null,"status":{"Ok":null},"fee":0,"preBalances":[],"postBalances":[],"preTokenBalances":[{"accountIndex":255,"mint":"11111111111111111111111111111111","uiTokenAmount":{"amount":"0","decimals":0,"uiAmount":0,"uiAmountString":"0"}}],"innerInstructions":[{"index":255,"instructions":[{"programIdIndex":255,"accounts":[255],"data":"","stackHeight":4294967295}]}]}}"""; + + // Act + var response = JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + response!.Meta!.PreTokenBalances.Should().ContainSingle().Which.AccountIndex.Should().Be(byte.MaxValue); + var group = response.Meta.InnerInstructions.Should().ContainSingle().Subject; + group.Index.Should().Be(byte.MaxValue); + var instruction = group.Instructions.Should().ContainSingle().Subject; + instruction.ProgramIdIndex.Should().Be(byte.MaxValue); + instruction.Accounts.Should().Equal(byte.MaxValue); + instruction.StackHeight.Should().Be(uint.MaxValue); + } + + [TestCase("\"preTokenBalances\":[{\"accountIndex\":256,\"mint\":\"11111111111111111111111111111111\",\"uiTokenAmount\":{\"amount\":\"0\",\"decimals\":0,\"uiAmount\":0,\"uiAmountString\":\"0\"}}]")] + [TestCase("\"innerInstructions\":[{\"index\":256,\"instructions\":[]}]")] + [TestCase("\"innerInstructions\":[{\"index\":0,\"instructions\":[{\"programIdIndex\":256,\"accounts\":[],\"data\":\"\",\"stackHeight\":null}]}]")] + [TestCase("\"innerInstructions\":[{\"index\":0,\"instructions\":[{\"programIdIndex\":0,\"accounts\":[256],\"data\":\"\",\"stackHeight\":null}]}]")] + [TestCase("\"innerInstructions\":[{\"index\":0,\"instructions\":[{\"programIdIndex\":0,\"accounts\":[],\"data\":\"\",\"stackHeight\":4294967296}]}]")] + public void IntegerWidthOverflow_ThrowsJsonException(string member) + { + // Arrange + var json = + "{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":{\"err\":null," + + "\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":[]," + member + "}}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{}")] + [TestCase("{\"index\":0}")] + [TestCase("{\"instructions\":[]}")] + [TestCase("{\"index\":0,\"instructions\":null}")] + [TestCase("{\"index\":0,\"instructions\":[null]}")] + [TestCase("{\"index\":0,\"instructions\":[{}]}")] + [TestCase("{\"index\":0,\"instructions\":[{\"accounts\":[],\"data\":\"\",\"stackHeight\":null}]}")] + [TestCase("{\"index\":0,\"instructions\":[{\"programIdIndex\":0,\"data\":\"\",\"stackHeight\":null}]}")] + [TestCase("{\"index\":0,\"instructions\":[{\"programIdIndex\":0,\"accounts\":[],\"stackHeight\":null}]}")] + [TestCase("{\"index\":0,\"instructions\":[{\"programIdIndex\":0,\"accounts\":[],\"data\":\"\"}]}")] + [TestCase("{\"index\":0,\"instructions\":[{\"programIdIndex\":0,\"accounts\":null,\"data\":\"\",\"stackHeight\":null}]}")] + [TestCase("{\"index\":0,\"instructions\":[{\"programIdIndex\":0,\"accounts\":[],\"data\":null,\"stackHeight\":null}]}")] + public void MalformedCompiledInnerInstructions_ThrowsJsonException(string group) + { + // Arrange + var json = + "{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":{\"err\":null," + + "\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":[]," + + "\"innerInstructions\":[" + group + "]}}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{\"Ok\":null}", "{\"InstructionError\":[0,\"Custom\"]}")] + [TestCase("{\"Err\":{\"InstructionError\":[0,\"Custom\"]}}", "null")] + [TestCase("{\"Err\":null}", "{\"InstructionError\":[0,\"Custom\"]}")] + [TestCase("{\"Err\":{\"InstructionError\":[1,\"Custom\"]}}", "{\"InstructionError\":[0,\"Custom\"]}")] + [TestCase("{}", "null")] + [TestCase("{\"Ok\":null,\"Err\":{}}", "null")] + public void InconsistentStatusAndError_ThrowsJsonException(string status, string error) + { + // Arrange + var json = + "{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":{\"err\":" + + error + ",\"status\":" + status + ",\"fee\":0,\"preBalances\":[],\"postBalances\":[]}}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{\"accountIndex\":0,\"uiTokenAmount\":{\"amount\":\"0\",\"decimals\":0,\"uiAmount\":0,\"uiAmountString\":\"0\"}}")] + [TestCase("{\"mint\":\"11111111111111111111111111111111\",\"uiTokenAmount\":{\"amount\":\"0\",\"decimals\":0,\"uiAmount\":0,\"uiAmountString\":\"0\"}}")] + [TestCase("{\"accountIndex\":0,\"mint\":\"11111111111111111111111111111111\"}")] + [TestCase("{\"accountIndex\":0,\"mint\":\"11111111111111111111111111111111\",\"uiTokenAmount\":null}")] + public void TokenBalanceMissingMandatoryField_ThrowsJsonException(string balance) + { + // Arrange + var json = + "{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":{\"err\":null," + + "\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":[],\"preTokenBalances\":[" + + balance + "]}}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("\"preTokenBalances\":[null]")] + [TestCase("\"postTokenBalances\":[null]")] + [TestCase("\"innerInstructions\":[null]")] + [TestCase("\"logMessages\":[null]")] + [TestCase("\"rewards\":[null]")] + public void NullEntryInOptionalMetadataCollection_ThrowsJsonException(string member) + { + // Arrange + var json = + "{\"slot\":0,\"blockTime\":null,\"transaction\":[\"\",\"base64\"],\"meta\":{\"err\":null," + + "\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":[]," + member + "}}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + } +} + +public static class ParsedTransactionJsonTests +{ + [TestFixture] + public sealed class Read + { + [TestCase("{}")] + [TestCase("{\"transaction\":null}")] + [TestCase("{\"transaction\":[]}")] + [TestCase("{\"transaction\":{}}")] + [TestCase("{\"transaction\":{\"signatures\":null,\"message\":{\"accountKeys\":[],\"instructions\":[],\"recentBlockhash\":\"\"}}}")] + [TestCase("{\"transaction\":{\"signatures\":[null],\"message\":{\"accountKeys\":[],\"instructions\":[],\"recentBlockhash\":\"\"}}}")] + [TestCase("{\"transaction\":{\"signatures\":[]}}")] + [TestCase("{\"transaction\":{\"signatures\":[],\"message\":null}}")] + public void MalformedTransactionEnvelope_ThrowsJsonException(string json) + { + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{\"instructions\":[],\"recentBlockhash\":\"\"}")] + [TestCase("{\"accountKeys\":[],\"recentBlockhash\":\"\"}")] + [TestCase("{\"accountKeys\":[],\"instructions\":[]}")] + [TestCase("{\"accountKeys\":null,\"instructions\":[],\"recentBlockhash\":\"\"}")] + [TestCase("{\"accountKeys\":[null],\"instructions\":[],\"recentBlockhash\":\"\"}")] + [TestCase("{\"accountKeys\":[],\"instructions\":null,\"recentBlockhash\":\"\"}")] + [TestCase("{\"accountKeys\":[],\"instructions\":[null],\"recentBlockhash\":\"\"}")] + [TestCase("{\"accountKeys\":[],\"instructions\":[],\"recentBlockhash\":null}")] + public void MalformedMessage_ThrowsJsonException(string message) + { + // Arrange + var json = "{\"transaction\":{\"signatures\":[],\"message\":" + message + "},\"meta\":null}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{}")] + [TestCase("{\"accountKey\":\"11111111111111111111111111111111\",\"readonlyIndexes\":[]}")] + [TestCase("{\"accountKey\":\"11111111111111111111111111111111\",\"writableIndexes\":[]}")] + [TestCase("{\"accountKey\":\"11111111111111111111111111111111\",\"writableIndexes\":null,\"readonlyIndexes\":[]}")] + [TestCase("{\"accountKey\":\"11111111111111111111111111111111\",\"writableIndexes\":[],\"readonlyIndexes\":null}")] + public void MalformedAddressTableLookup_ThrowsJsonException(string lookup) + { + // Arrange + var json = + "{\"transaction\":{\"signatures\":[],\"message\":{\"accountKeys\":[],\"instructions\":[]," + + "\"recentBlockhash\":\"\",\"addressTableLookups\":[" + lookup + "]}},\"meta\":null}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("\"Legacy\"")] + [TestCase("\"v0\"")] + [TestCase("-1")] + [TestCase("256")] + [TestCase("1.0")] + public void InvalidVersion_ThrowsJsonException(string version) + { + // Arrange + var json = + "{\"transaction\":{\"signatures\":[],\"message\":{\"accountKeys\":[],\"instructions\":[]," + + "\"recentBlockhash\":\"\"}},\"meta\":null,\"version\":" + version + "}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{}")] + [TestCase("{\"version\":null}")] + public void OptionalVersionAbsentOrNull_IsPreservedAsNull(string optionalMember) + { + // Arrange + using var optional = JsonDocument.Parse(optionalMember); + var version = optional.RootElement.TryGetProperty("version", out var member) + ? ",\"version\":" + member.GetRawText() + : string.Empty; + var json = + "{\"transaction\":{\"signatures\":[],\"message\":{\"accountKeys\":[],\"instructions\":[],\"recentBlockhash\":\"\"}}" + + ",\"meta\":null" + version + "}"; + + // Act + var transaction = JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + transaction!.Version.Should().BeNull(); + } + + [Test] + public void MetadataMemberMissing_ThrowsJsonException() + { + // Arrange + const string json = + """{"transaction":{"signatures":[],"message":{"accountKeys":[],"instructions":[],"recentBlockhash":""}}}"""; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw().WithMessage("*metadata member*"); + } + + [TestCase("\"slot\":0")] + [TestCase("\"blockTime\":null")] + [TestCase("\"slot\":null,\"blockTime\":null")] + [TestCase("\"slot\":-1,\"blockTime\":null")] + [TestCase("\"slot\":\"0\",\"blockTime\":null")] + [TestCase("\"slot\":0,\"blockTime\":\"0\"")] + [TestCase("\"transactionIndex\":null")] + [TestCase("\"transactionIndex\":-1")] + [TestCase("\"transactionIndex\":4294967296")] + public void MalformedPositionMember_ThrowsJsonException(string members) + { + // Arrange + var json = + "{\"transaction\":{\"signatures\":[],\"message\":{\"accountKeys\":[],\"instructions\":[]," + + "\"recentBlockhash\":\"\"}},\"meta\":null," + members + "}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{\"signer\":false,\"writable\":false,\"source\":null}")] + [TestCase("{\"pubkey\":\"11111111111111111111111111111111\",\"writable\":false,\"source\":null}")] + [TestCase("{\"pubkey\":\"11111111111111111111111111111111\",\"signer\":false,\"source\":null}")] + [TestCase("{\"pubkey\":\"11111111111111111111111111111111\",\"signer\":false,\"writable\":false}")] + [TestCase("{\"pubkey\":\"11111111111111111111111111111111\",\"signer\":false,\"writable\":false,\"source\":\"static\"}")] + public void MalformedAccountKey_ThrowsJsonException(string accountKey) + { + // Arrange + var json = + "{\"transaction\":{\"signatures\":[],\"message\":{\"accountKeys\":[" + accountKey + + "],\"instructions\":[],\"recentBlockhash\":\"\"}},\"meta\":null}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("{}")] + [TestCase("{\"priorityFee\":null,\"computeUnitLimit\":null,\"loadedAccountsDataSizeLimit\":null}")] + public void MalformedTransactionConfig_ThrowsJsonException(string config) + { + // Arrange + var json = + "{\"transaction\":{\"signatures\":[],\"message\":{\"accountKeys\":[],\"instructions\":[]," + + "\"recentBlockhash\":\"\",\"transactionConfig\":" + config + "}},\"meta\":null}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void NullTransactionConfigOptions_ArePreserved() + { + // Arrange + const string json = + """{"transaction":{"signatures":[],"message":{"accountKeys":[],"instructions":[],"recentBlockhash":"","transactionConfig":{"priorityFee":null,"computeUnitLimit":null,"loadedAccountsDataSizeLimit":null,"heapSize":null}}},"meta":null}"""; + + // Act + var transaction = JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + transaction!.Message.TransactionConfig.Should().NotBeNull(); + transaction.Message.TransactionConfig!.PriorityFee.Should().BeNull(); + transaction.Message.TransactionConfig.ComputeUnitLimit.Should().BeNull(); + transaction.Message.TransactionConfig.LoadedAccountsDataSizeLimit.Should().BeNull(); + transaction.Message.TransactionConfig.HeapSize.Should().BeNull(); + } + + [TestCase("{}")] + [TestCase("{\"program\":\"system\",\"programId\":\"11111111111111111111111111111111\",\"parsed\":{},\"stackHeight\":null,\"accounts\":[],\"data\":\"\"}")] + [TestCase("{\"programId\":\"11111111111111111111111111111111\",\"accounts\":[],\"data\":\"\",\"stackHeight\":null,\"program\":\"system\",\"parsed\":{}}")] + [TestCase("{\"program\":\"system\",\"programId\":\"11111111111111111111111111111111\",\"parsed\":{}}")] + [TestCase("{\"programId\":\"11111111111111111111111111111111\",\"accounts\":[],\"data\":\"\"}")] + [TestCase("{\"program\":\"system\",\"parsed\":{},\"stackHeight\":null}")] + [TestCase("{\"program\":\"system\",\"programId\":\"11111111111111111111111111111111\",\"stackHeight\":null}")] + public void MalformedInstructionUnion_ThrowsJsonException(string instruction) + { + // Arrange + var json = + "{\"transaction\":{\"signatures\":[],\"message\":{\"accountKeys\":[],\"instructions\":[" + + instruction + "],\"recentBlockhash\":\"\"}},\"meta\":null}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void BothInstructionBranches_PreserveExactValues() + { + // Arrange + const string json = + """{"transaction":{"signatures":[],"message":{"accountKeys":[],"instructions":[{"program":"system","programId":"11111111111111111111111111111111","parsed":null,"stackHeight":4294967295},{"programId":"11111111111111111111111111111111","accounts":["11111111111111111111111111111111"],"data":"","stackHeight":null}],"recentBlockhash":""}},"meta":null}"""; + + // Act + var transaction = JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + var parsed = transaction!.Message.Instructions[0]; + parsed.Program.Should().Be("system"); + parsed.Parsed.Should().NotBeNull(); + parsed.Parsed!.Info.ValueKind.Should().Be(JsonValueKind.Null); + parsed.StackHeight.Should().Be(uint.MaxValue); + var partial = transaction.Message.Instructions[1]; + partial.Program.Should().BeNull(); + partial.Parsed.Should().BeNull(); + partial.Accounts.Should().ContainSingle(); + partial.Data.Should().BeEmpty(); + } + + [Test] + public void ArbitraryParsedObjectShape_IsPreservedWithoutProjectionFailure() + { + // Arrange + const string json = + """{"transaction":{"signatures":[],"message":{"accountKeys":[],"instructions":[{"program":"custom","programId":"11111111111111111111111111111111","parsed":{"type":42,"info":{"x":1}},"stackHeight":null}],"recentBlockhash":""}},"meta":null}"""; + + // Act + var transaction = JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + var parsed = transaction!.Message.Instructions.Should().ContainSingle().Subject.Parsed!; + parsed.Type.Should().BeEmpty(); + parsed.Info.GetProperty("type").GetInt32().Should().Be(42); + parsed.Info.GetProperty("info").GetProperty("x").GetInt32().Should().Be(1); + } + + [TestCase("{}")] + [TestCase("{\"index\":0}")] + [TestCase("{\"instructions\":[]}")] + [TestCase("{\"index\":0,\"instructions\":null}")] + [TestCase("{\"index\":0,\"instructions\":[null]}")] + [TestCase("{\"index\":256,\"instructions\":[]}")] + public void MalformedParsedInnerInstructions_ThrowsJsonException(string group) + { + // Arrange + var json = + "{\"err\":null,\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":[]," + + "\"innerInstructions\":[" + group + "]}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void ParsedInnerInstructionWidths_AcceptBoundaries() + { + // Arrange + const string json = + """{"err":null,"status":{"Ok":null},"fee":0,"preBalances":[],"postBalances":[],"innerInstructions":[{"index":255,"instructions":[{"program":"system","programId":"11111111111111111111111111111111","parsed":{},"stackHeight":4294967295}]}]}"""; + + // Act + var metadata = JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + var group = metadata!.InnerInstructions.Should().ContainSingle().Subject; + group.Index.Should().Be(byte.MaxValue); + group.Instructions.Should().ContainSingle().Which.StackHeight.Should().Be(uint.MaxValue); + } + + [Test] + public void ParsedInstructionStackHeightOverflow_ThrowsJsonException() + { + // Arrange + const string json = + """{"transaction":{"signatures":[],"message":{"accountKeys":[],"instructions":[{"program":"system","programId":"11111111111111111111111111111111","parsed":{},"stackHeight":4294967296}],"recentBlockhash":""}},"meta":null}"""; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void InconsistentParsedStatusAndError_ThrowsJsonException() + { + // Arrange + const string json = + """{"err":{"InstructionError":[0,"Custom"]},"status":{"Ok":null},"fee":0,"preBalances":[],"postBalances":[]}"""; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [TestCase("\"preTokenBalances\":[null]")] + [TestCase("\"postTokenBalances\":[null]")] + [TestCase("\"innerInstructions\":[null]")] + [TestCase("\"logMessages\":[null]")] + [TestCase("\"rewards\":[null]")] + public void NullEntryInOptionalParsedMetadataCollection_ThrowsJsonException(string member) + { + // Arrange + var json = + "{\"err\":null,\"status\":{\"Ok\":null},\"fee\":0,\"preBalances\":[],\"postBalances\":[]," + + member + "}"; + + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + } +} + +public static class ParsedBlockJsonTests +{ + [TestFixture] + public sealed class Deserialize + { + [TestCase("{}")] + [TestCase("{\"previousBlockhash\":\"\",\"parentSlot\":0,\"blockTime\":null,\"blockHeight\":null,\"transactions\":[]}")] + [TestCase("{\"blockhash\":\"\",\"parentSlot\":0,\"blockTime\":null,\"blockHeight\":null,\"transactions\":[]}")] + [TestCase("{\"blockhash\":\"\",\"previousBlockhash\":\"\",\"blockTime\":null,\"blockHeight\":null,\"transactions\":[]}")] + [TestCase("{\"blockhash\":\"\",\"previousBlockhash\":\"\",\"parentSlot\":0,\"blockHeight\":null,\"transactions\":[]}")] + [TestCase("{\"blockhash\":\"\",\"previousBlockhash\":\"\",\"parentSlot\":0,\"blockTime\":null,\"transactions\":[]}")] + [TestCase("{\"blockhash\":\"\",\"previousBlockhash\":\"\",\"parentSlot\":0,\"blockTime\":null,\"blockHeight\":null,\"transactions\":null}")] + [TestCase("{\"blockhash\":\"\",\"previousBlockhash\":\"\",\"parentSlot\":0,\"blockTime\":null,\"blockHeight\":null,\"transactions\":[null]}")] + public void MalformedBlock_ThrowsJsonException(string json) + { + // Act + Action act = () => JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + act.Should().Throw(); + } + + [Test] + public void NullableBlockFieldsPresent_ArePreserved() + { + // Arrange + const string json = + """{"blockhash":"","previousBlockhash":"","parentSlot":0,"blockTime":null,"blockHeight":null,"transactions":[]}"""; + + // Act + var block = JsonSerializer.Deserialize(json, RpcJson.Options); + + // Assert + block!.BlockTime.Should().BeNull(); + block.BlockHeight.Should().BeNull(); + block.Transactions.Should().BeEmpty(); + } + } +} diff --git a/tests/SolSharp.Wallet.Tests/BlsAggregationTests.cs b/tests/SolSharp.Wallet.Tests/BlsAggregationTests.cs new file mode 100644 index 0000000..ca759e4 --- /dev/null +++ b/tests/SolSharp.Wallet.Tests/BlsAggregationTests.cs @@ -0,0 +1,358 @@ +using System.Security.Cryptography; +using FluentAssertions; +using NUnit.Framework; +using static SolSharp.Wallet.Tests.BlsAggregationVectors; +using Backend = Nethermind.Crypto.Bls; + +namespace SolSharp.Wallet.Tests; + +public static class BlsPublicKeyTests +{ + [TestFixture] + public sealed class TryParse + { + [Test] + public void RejectsInfinityWrongSubgroupAndNoncanonicalTextBeforeDecode() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial(0)); + var infinity = new byte[BlsPublicKey.Length]; + infinity[0] = 0xc0; + var base64 = keypair.PublicKey.ToString(); + + // Act & Assert + BlsPublicKey.TryParse(infinity, out _).Should().BeFalse(); + BlsPublicKey.TryParse(Convert.FromHexString(G1WrongSubgroup), out _).Should().BeFalse(); + BlsPublicKey.TryParse(new string('A', 1_000_000), out _).Should().BeFalse(); + BlsPublicKey.TryParse(ReplaceAt(base64, 1, ' '), out _).Should().BeFalse(); + BlsPublicKey.TryParse(ReplaceAt(base64, ^1, '='), out _).Should().BeFalse(); + BlsPublicKey.TryParse(ReplaceAt(base64, 2, '*'), out _).Should().BeFalse(); + } + } + + [TestFixture] + public sealed class VerifyAndWrapProofOfPossession + { + [Test] + public void ValidProofCreatesTypedProvenanceWhileMismatchesThrow() + { + // Arrange + using var first = BlsKeypair.Derive(InputKeyMaterial(0)); + using var second = BlsKeypair.Derive(InputKeyMaterial(1)); + var proof = first.CreateProofOfPossession("registry"u8); + + // Act + var verified = first.PublicKey.VerifyAndWrapProofOfPossession(proof, "registry"u8); + Action wrongKey = () => _ = second.PublicKey.VerifyAndWrapProofOfPossession(proof, "registry"u8); + Action wrongPayload = () => _ = first.PublicKey.VerifyAndWrapProofOfPossession(proof, "other"u8); + + // Assert + verified.PublicKey.Should().Be(first.PublicKey); + wrongKey.Should().Throw(); + wrongPayload.Should().Throw(); + } + } +} + +public static class BlsPopVerifiedPublicKeyTests +{ + [TestFixture] + public sealed class Verify + { + [Test] + public void VerifiedProofWrapperCanVerifySignatures() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial(0)); + var proof = keypair.CreateProofOfPossession("registry"u8); + var verified = keypair.PublicKey.VerifyAndWrapProofOfPossession(proof, "registry"u8); + var signature = keypair.Sign("message"u8); + + // Act + var valid = verified.Verify(signature, "message"u8); + var wrongMessage = verified.Verify(signature, "other"u8); + + // Assert + valid.Should().BeTrue(); + wrongMessage.Should().BeFalse(); + } + } +} + +public static class BlsSignatureTests +{ + [TestFixture] + public sealed class TryParse + { + [Test] + public void RejectsInfinityWrongSubgroupMalformedAndNoncanonicalTextBeforeDecode() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial(0)); + var signature = keypair.Sign("message"u8); + var infinity = new byte[BlsSignature.Length]; + infinity[0] = 0xc0; + var wrongSubgroup = Convert.FromHexString(G2WrongSubgroup); + var base64 = signature.ToString(); + + // Act & Assert + BlsSignature.TryParse(infinity, out _).Should().BeFalse(); + BlsOperations.GetG2ValidationResult(wrongSubgroup) + .Should().Be(BlsPointValidationResult.NotInGroup); + BlsSignature.TryParse(wrongSubgroup, out _).Should().BeFalse(); + BlsOperations.GetG2ValidationResult(new byte[BlsSignature.Length]) + .Should().Be(BlsPointValidationResult.BadEncoding); + BlsSignature.TryParse(ReplaceAt(base64, 1, '\n'), out _).Should().BeFalse(); + BlsSignature.TryParse(ReplaceAt(base64, ^1, '='), out _).Should().BeFalse(); + } + } + + [TestFixture] + public sealed class Aggregate + { + [Test] + public void PinnedUpstreamVector_MatchesCanonicalCompressedAggregate() + { + // Arrange + BlsSignature[] signatures = + [ + BlsSignature.Parse(Convert.FromHexString(AggregateSignatureOne)), + BlsSignature.Parse(Convert.FromHexString(AggregateSignatureTwo)), + BlsSignature.Parse(Convert.FromHexString(AggregateSignatureThree)) + ]; + + // Act + var aggregate = BlsSignature.Aggregate(signatures); + + // Assert + Convert.ToHexString(aggregate.ToBytes()).Should().Be(ExpectedAggregateSignature); + } + + [Test] + public void EmptyNullEntryAndInfinityResult_AreRejected() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial(0)); + var signature = keypair.Sign("message"u8); + var negative = BlsSignature.Parse(new Backend.P2(signature.Bytes).Neg().Compress()); + BlsSignature[] nullEntry = [signature, null!]; + + // Act + Action empty = () => _ = BlsSignature.Aggregate([]); + Action containsNull = () => _ = BlsSignature.Aggregate(nullEntry); + Action infinity = () => _ = BlsSignature.Aggregate([signature, negative]); + + // Assert + empty.Should().Throw(); + containsNull.Should().Throw(); + infinity.Should().Throw().WithMessage("*point at infinity*"); + } + + [Test] + public void MalformedInfinityAndWrongSubgroupInputs_CannotEnterAggregation() + { + // Arrange + var infinity = new byte[BlsSignature.Length]; + infinity[0] = 0xc0; + + // Act & Assert + BlsSignature.TryParse([1], out _).Should().BeFalse(); + BlsSignature.TryParse(infinity, out _).Should().BeFalse(); + BlsSignature.TryParse(Convert.FromHexString(G2WrongSubgroup), out _).Should().BeFalse(); + } + } +} + +public static class BlsProofOfPossessionTests +{ + [TestFixture] + public sealed class TryParse + { + [Test] + public void RejectsInfinityMalformedAndNoncanonicalTextBeforeDecode() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial(0)); + var proof = keypair.CreateProofOfPossession("payload"u8); + var infinity = new byte[BlsProofOfPossession.Length]; + infinity[0] = 0xc0; + + // Act & Assert + BlsProofOfPossession.TryParse(infinity, out _).Should().BeFalse(); + BlsProofOfPossession.TryParse("not base64", out _).Should().BeFalse(); + BlsProofOfPossession.TryParse(ReplaceAt(proof.ToString(), 2, '*'), out _).Should().BeFalse(); + } + } +} + +public static class BlsAggregatePublicKeyTests +{ + [TestFixture] + public sealed class Aggregate + { + [Test] + public void PinnedUpstreamFastAggregateVector_MatchesCanonicalCompressedKey() + { + // Arrange: the upstream fixture marks these public keys as PopVerified before aggregation. + BlsPopVerifiedPublicKey[] publicKeys = + [ + new(BlsPublicKey.Parse(Convert.FromHexString(FastAggregatePublicKeyOne))), + new(BlsPublicKey.Parse(Convert.FromHexString(FastAggregatePublicKeyTwo))) + ]; + + // Act + var aggregate = BlsAggregatePublicKey.Aggregate(publicKeys); + var repeated = BlsAggregatePublicKey.Aggregate(publicKeys); + + // Assert + Convert.ToHexString(aggregate.ToBytes()).Should().Be(ExpectedAggregatePublicKey); + aggregate.Should().Be(repeated); + aggregate.GetHashCode().Should().Be(repeated.GetHashCode()); + aggregate.ToString().Should().Be(Convert.ToBase64String(aggregate.ToBytes())); + } + + [Test] + public void EmptyNullEntryAndInfinityResult_AreRejected() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial(0)); + var publicKey = keypair.PublicKey; + var negative = BlsPublicKey.Parse(new Backend.P1(publicKey.Bytes).Neg().Compress()); + var verified = new BlsPopVerifiedPublicKey(publicKey); + var negativeVerified = new BlsPopVerifiedPublicKey(negative); + BlsPopVerifiedPublicKey[] nullEntry = [verified, null!]; + + // Act + Action empty = () => _ = BlsAggregatePublicKey.Aggregate([]); + Action containsNull = () => _ = BlsAggregatePublicKey.Aggregate(nullEntry); + Action infinity = () => _ = BlsAggregatePublicKey.Aggregate([verified, negativeVerified]); + + // Assert + empty.Should().Throw(); + containsNull.Should().Throw(); + infinity.Should().Throw().WithMessage("*point at infinity*"); + } + + [Test] + public void MalformedInfinityAndWrongSubgroupInputs_CannotEnterAggregation() + { + // Arrange + var infinity = new byte[BlsPublicKey.Length]; + infinity[0] = 0xc0; + + // Act & Assert + BlsPublicKey.TryParse([1], out _).Should().BeFalse(); + BlsPublicKey.TryParse(infinity, out _).Should().BeFalse(); + BlsPublicKey.TryParse(Convert.FromHexString(G1WrongSubgroup), out _).Should().BeFalse(); + } + } + + [TestFixture] + public sealed class Verify + { + [Test] + public void PinnedUpstreamFastAggregateVector_VerifiesSharedMessageOnly() + { + // Arrange: the upstream fixture marks these public keys as PopVerified before aggregation. + BlsPopVerifiedPublicKey[] publicKeys = + [ + new(BlsPublicKey.Parse(Convert.FromHexString(FastAggregatePublicKeyOne))), + new(BlsPublicKey.Parse(Convert.FromHexString(FastAggregatePublicKeyTwo))) + ]; + var aggregate = BlsAggregatePublicKey.Aggregate(publicKeys); + var signature = BlsSignature.Parse(Convert.FromHexString(FastAggregateSignature)); + var message = Convert.FromHexString( + "5656565656565656565656565656565656565656565656565656565656565656"); + + // Act + var valid = aggregate.Verify(signature, message); + var wrongMessage = aggregate.Verify(signature, "wrong"u8); + + // Assert + valid.Should().BeTrue(); + wrongMessage.Should().BeFalse(); + } + + [Test] + public void PopVerifiedParticipantsAndDuplicateSigner_MatchUpstreamSemantics() + { + // Arrange + using var first = BlsKeypair.Derive(InputKeyMaterial(0)); + using var second = BlsKeypair.Derive(InputKeyMaterial(1)); + var firstVerified = first.PublicKey.VerifyAndWrapProofOfPossession( + first.CreateProofOfPossession("registry"u8), + "registry"u8); + var secondVerified = second.PublicKey.VerifyAndWrapProofOfPossession( + second.CreateProofOfPossession("registry"u8), + "registry"u8); + var firstSignature = first.Sign("shared"u8); + var secondSignature = second.Sign("shared"u8); + var aggregateKey = BlsAggregatePublicKey.Aggregate([firstVerified, secondVerified]); + var aggregateSignature = BlsSignature.Aggregate([firstSignature, secondSignature]); + var duplicateKey = BlsAggregatePublicKey.Aggregate([firstVerified, firstVerified]); + var duplicateSignature = BlsSignature.Aggregate([firstSignature, firstSignature]); + + // Act + var valid = aggregateKey.Verify(aggregateSignature, "shared"u8); + var wrongMessage = aggregateKey.Verify(aggregateSignature, "other"u8); + var duplicateValid = duplicateKey.Verify(duplicateSignature, "shared"u8); + var missingDuplicateSignature = duplicateKey.Verify(firstSignature, "shared"u8); + + // Assert + valid.Should().BeTrue(); + wrongMessage.Should().BeFalse(); + duplicateValid.Should().BeTrue(); + missingDuplicateSignature.Should().BeFalse(); + } + } +} + +internal static class BlsAggregationVectors +{ + // Ethereum consensus-spec v0.1.2 vectors executed by pinned solana-bls-signatures 3.4.0. + internal const string AggregateSignatureOne = + "91347BCCF740D859038FCDCAF233EECEB2A436BCAAEE9B2AA3BFB70EFE29DFB2677562CCBEA1C8E061FB9971B0753C24" + + "0622FAB78489CE96768259FC01360346DA5B9F579E5DA0D941E4C6BA18A0E64906082375394F337FA1AF2B7127B0D121"; + + internal const string AggregateSignatureTwo = + "9674E2228034527F4C083206032B020310FACE156D4A4685E2FCAEC2F6F3665AA635D90347B6CE124EB879266B1E801D" + + "185DE36A0A289B85E9039662634F2EEA1E02E670BC7AB849D006A70B2F93B84597558A05B879C8D445F387A5D5B653DF"; + + internal const string AggregateSignatureThree = + "AE82747DDEEFE4FD64CF9CEDB9B04AE3E8A43420CD255E3C7CD06A8D88B7C7F8638543719981C5D16FA3527C468C25F0" + + "026704A6951BDE891360C7E8D12DDEE0559004CCDBE6046B55BAE1B257EE97F7CDB955773D7CF29ADF3CCBB9975E4EB9"; + + internal const string ExpectedAggregateSignature = + "9712C3EDD73A209C742B8250759DB12549B3EAF43B5CA61376D9F30E2747DBCF842D8B2AC0901D2A093713E20284A767" + + "0FCF6954E9AB93DE991BB9B313E664785A075FC285806FA5224C82BDE146561B446CCFC706A64B8579513CFC4FF1D930"; + + internal const string FastAggregatePublicKeyOne = + "A491D1B0ECD9BB917989F0E74F0DEA0422EAC4A873E5E2644F368DFFB9A6E20FD6E10C1B77654D067C0618F6E5A7F79A"; + + internal const string FastAggregatePublicKeyTwo = + "B301803F8B5AC4A1133581FC676DFEDC60D891DD5FA99028805E5EA5B08D3491AF75D0707ADAB3B70C6A6A580217BF81"; + + internal const string ExpectedAggregatePublicKey = + "A10D7B8A1F6B4B3E7048D06478B88C0F2257F0517B12FDFE59E33EC6240C39F9FC7D4F04E8A37C33E64258ED2FA45850"; + + internal const string FastAggregateSignature = + "912C3615F69575407DB9392EB21FEE18FFF797EEB2FBE1816366CA2A08AE574D8824DBFAFB4C9EAA1CF61B63C6F9B699" + + "11F269B664C42947DD1B53EF1081926C1E82BB2A465F927124B08391A5249036146D6F3F1E17FF5F162F779746D830D1"; + + internal const string G1WrongSubgroup = + "8123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + + internal const string G2WrongSubgroup = + "94CD598E4DA3827FBF72D44ABB50D8B71867B55A453DACF4AACE5CE78222DCB6C2DA6B22A47B24CC7E9A358C88A642C8" + + "1495F5519F918FEA72747905FD0EA49C264EA0F8FFBE17AAF583B21CD9838D246593B5BF94BDDE84191F68C29936EE28"; + + internal static byte[] InputKeyMaterial(int offset) => + [.. Enumerable.Range(offset, BlsKeypair.MinimumInputKeyMaterialLength).Select(value => checked((byte)value))]; + + internal static string ReplaceAt(string value, Index index, char replacement) + { + var chars = value.ToCharArray(); + chars[index] = replacement; + return new string(chars); + } +} diff --git a/tests/SolSharp.Wallet.Tests/BlsKeypairTests.cs b/tests/SolSharp.Wallet.Tests/BlsKeypairTests.cs new file mode 100644 index 0000000..0c214d3 --- /dev/null +++ b/tests/SolSharp.Wallet.Tests/BlsKeypairTests.cs @@ -0,0 +1,482 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Wallet.Tests; + +public static class BlsKeypairTests +{ + // Pinned solana-bls-signatures 3.4.0 contracts: blst_keygen, minimal-pubkey-size POP ciphersuite, + // little-endian scalar export, SIG/POP DSTs, and payload || compressed-public-key PoP binding. + private const string ExpectedSecret = + "5634DB5DF13CE91CDE735366556FD174B4C111BC064E262BA3B037E3B70D3623"; + + private const string ExpectedPublicKey = + "9112A0386A2340714BA0C6D2DF235377A8679C3899D03E6EF04DBA7A50EF49E5A1DC93105E9374E93ED301B63487E17C"; + + private const string ExpectedUncompressedPublicKey = + "1112A0386A2340714BA0C6D2DF235377A8679C3899D03E6EF04DBA7A50EF49E5A1DC93105E9374E93ED301B63487E17C" + + "09D2DD6CA41991204A237C372B5008EA3B4DBD87DE217363ACBEAE295706ECE8659D72829CD95B41D1CBE377CA832008"; + + private const string ExpectedSignature = + "92AE0C49166D44E8A35E2EF7A94673F75F1A7BC48D69C8906E9B40A9149FEFCBE2A710E6BE3C1B7E27A35003B2B93FC3" + + "094AEBBEC838FFFBC1010E0D419C546BF861BA141ECCEC9AE7F4D83C457CFFD0F7E1A68E9B8E1099FED204762CD0CBF7"; + + private const string ExpectedVoteProof = + "94DF8CAD9915EF9E41269D181CD2DB7FE0590D52BBB1C10352CA557B454FE0732F0EF953F1938B7C019B75EB7A7831BA" + + "02B385EDA726BDDC81B84E4B2D95435F6DD947E5CCCD22F4D311BE7BDCE02B0DC3E5B2B428F14F6AB1421B246BA0A5BF"; + + private static byte[] InputKeyMaterial => [.. Enumerable.Range(0, 32).Select(value => (byte)value)]; + + private static PublicKey VoteAccount => + new(Enumerable.Range(0, 32).Select(value => (byte)(0x80 + value)).ToArray()); + + [TestFixture] + public sealed class Generate + { + [Test] + public void ReturnsIndependentCanonicalKeypairs() + { + // Act + using var first = BlsKeypair.Generate(); + using var second = BlsKeypair.Generate(); + var firstSecret = first.ToSecretKeyBytes(); + var secondSecret = second.ToSecretKeyBytes(); + + try + { + // Assert + first.PublicKey.ToBytes().Should().HaveCount(BlsPublicKey.Length); + second.PublicKey.Should().NotBe(first.PublicKey); + firstSecret.Should().HaveCount(BlsKeypair.SecretKeyLength); + secondSecret.Should().NotEqual(firstSecret); + } + finally + { + CryptographicOperations.ZeroMemory(firstSecret); + CryptographicOperations.ZeroMemory(secondSecret); + } + } + } + + [TestFixture] + public sealed class Derive + { + [Test] + public void PinnedRustSdkVector_MatchesSecretAndCompressedPublicKey() + { + // Act + using var keypair = BlsKeypair.Derive(InputKeyMaterial); + var secret = keypair.ToSecretKeyBytes(); + + try + { + // Assert + Convert.ToHexString(secret).Should().Be(ExpectedSecret); + Convert.ToHexString(keypair.PublicKey.ToBytes()).Should().Be(ExpectedPublicKey); + BlsPublicKey.Parse(keypair.PublicKey.ToBytes()).Should().Be(keypair.PublicKey); + } + finally + { + CryptographicOperations.ZeroMemory(secret); + } + } + + [TestCase(0)] + [TestCase(31)] + public void InputShorterThanSdkMinimum_IsRejected(int length) + { + // Act + Action act = () => _ = BlsKeypair.Derive(new byte[length]); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class FromSecretKey + { + [Test] + public void CanonicalLittleEndianSecret_RoundTripsButZeroAndNoncanonicalScalarsFail() + { + // Arrange + var expected = Convert.FromHexString(ExpectedSecret); + + // Act + using var imported = BlsKeypair.FromSecretKey(expected); + Action zero = () => _ = BlsKeypair.FromSecretKey(new byte[BlsKeypair.SecretKeyLength]); + Action noncanonical = () => _ = BlsKeypair.FromSecretKey( + Enumerable.Repeat(byte.MaxValue, BlsKeypair.SecretKeyLength).ToArray()); + + // Assert + imported.PublicKey.ToBytes().Should().Equal(Convert.FromHexString(ExpectedPublicKey)); + zero.Should().Throw(); + noncanonical.Should().Throw(); + } + } + + [TestFixture] + public sealed class FromBytes + { + [Test] + public void RustKeypairBytes_RoundTripWithDerivedPublicValidation() + { + // Arrange + var bytes = Convert.FromHexString(ExpectedSecret + ExpectedUncompressedPublicKey); + + try + { + // Act + using var imported = BlsKeypair.FromBytes(bytes); + bytes[^1] ^= 1; + Action mismatched = () => _ = BlsKeypair.FromBytes(bytes); + var importedBytes = imported.ToBytes(); + + try + { + // Assert + Convert.ToHexString(importedBytes).Should().Be(ExpectedSecret + ExpectedUncompressedPublicKey); + mismatched.Should().Throw().WithMessage("*does not match*"); + } + finally + { + CryptographicOperations.ZeroMemory(importedBytes); + } + } + finally + { + CryptographicOperations.ZeroMemory(bytes); + } + } + } + + [TestFixture] + public sealed class FromJsonArray + { + [Test] + public void StringAndUtf8CompatibilityOverloads_RoundTrip() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial); + var json = keypair.ToJsonArray(); + var utf8Json = keypair.ToJsonUtf8Bytes(); + + try + { + // Act + using var fromString = BlsKeypair.FromJsonArray(json); + using var fromUtf8 = BlsKeypair.FromJsonArray(utf8Json); + + // Assert + fromString.PublicKey.Should().Be(keypair.PublicKey); + fromUtf8.PublicKey.Should().Be(keypair.PublicKey); + } + finally + { + CryptographicOperations.ZeroMemory(utf8Json); + } + } + } + + [TestFixture] + public sealed class DeriveFromSigner + { + [Test] + public void NullSignerPlaceholder_IsRejectedBeforeKeyDerivation() + { + // Arrange + var signer = new NullSigner(new PublicKey(new byte[PublicKey.Length])); + + // Act + Action act = () => _ = BlsKeypair.DeriveFromSigner(signer, "seed"u8); + + // Assert + act.Should().Throw().WithMessage("*all-zero*"); + } + + [Test] + public void Ed25519SignerAndPublicSeed_DeterministicallyDomainSeparateDerivedKeys() + { + // Arrange + using var signer = Keypair.FromSeed(Enumerable.Repeat((byte)7, Keypair.SeedLength).ToArray()); + + // Act + using var first = BlsKeypair.DeriveFromSigner(signer, "first"u8); + using var repeat = BlsKeypair.DeriveFromSigner(signer, "first"u8); + using var second = BlsKeypair.DeriveFromSigner(signer, "second"u8); + + // Assert + first.PublicKey.Should().Be(repeat.PublicKey); + first.PublicKey.Should().NotBe(second.PublicKey); + } + } + + [TestFixture] + public sealed class ToSecretKeyBytes + { + [Test] + public void ReturnsCanonicalDefensiveCopy() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial); + + // Act + var first = keypair.ToSecretKeyBytes(); + var second = keypair.ToSecretKeyBytes(); + + try + { + // Assert + Convert.ToHexString(first).Should().Be(ExpectedSecret); + first[0] ^= byte.MaxValue; + Convert.ToHexString(second).Should().Be(ExpectedSecret); + } + finally + { + CryptographicOperations.ZeroMemory(first); + CryptographicOperations.ZeroMemory(second); + } + } + } + + [TestFixture] + public sealed class ToBytes + { + [Test] + public void MatchesPinnedRustKeypairRepresentation() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial); + + // Act + var bytes = keypair.ToBytes(); + + try + { + // Assert + Convert.ToHexString(bytes).Should().Be(ExpectedSecret + ExpectedUncompressedPublicKey); + } + finally + { + CryptographicOperations.ZeroMemory(bytes); + } + } + } + + [TestFixture] + public sealed class ToJsonArray + { + [Test] + public void StringCompatibilityExport_RoundTrips() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial); + + // Act + var json = keypair.ToJsonArray(); + using var imported = BlsKeypair.FromJsonArray(json); + + // Assert + json.Should().StartWith("[").And.EndWith("]"); + imported.PublicKey.Should().Be(keypair.PublicKey); + } + } + + [TestFixture] + public sealed class ToJsonUtf8Bytes + { + [Test] + public void ZeroableUtf8Export_RoundTrips() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial); + + // Act + var json = keypair.ToJsonUtf8Bytes(); + + try + { + using var imported = BlsKeypair.FromJsonArray(json); + + // Assert + json[0].Should().Be((byte)'['); + json[^1].Should().Be((byte)']'); + imported.PublicKey.Should().Be(keypair.PublicKey); + } + finally + { + CryptographicOperations.ZeroMemory(json); + } + } + } + + [TestFixture] + public sealed class Sign + { + [Test] + public void PinnedRustSdkVector_UsesExactSignatureDst() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial); + + // Act + var signature = keypair.Sign("SolSharp BLS KAT"u8); + + // Assert + Convert.ToHexString(signature.ToBytes()).Should().Be(ExpectedSignature); + BlsPublicKey.Parse(keypair.PublicKey.ToString()).Should().Be(keypair.PublicKey); + BlsSignature.Parse(signature.ToString()).Should().Be(signature); + BlsPublicKey.TryParse("not base64", out _).Should().BeFalse(); + BlsSignature.TryParse("not base64", out _).Should().BeFalse(); + } + } + + [TestFixture] + public sealed class Verify + { + [Test] + public void DerivedKeypairUsesPopVerifiedSignatureBoundary() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial); + var signature = keypair.Sign("SolSharp BLS KAT"u8); + + // Act + var valid = keypair.Verify(signature, "SolSharp BLS KAT"u8); + var wrongMessage = keypair.Verify(signature, "SolSharp BLS kat"u8); + + // Assert + valid.Should().BeTrue(); + wrongMessage.Should().BeFalse(); + keypair.PopVerifiedPublicKey.PublicKey.Should().Be(keypair.PublicKey); + } + } + + [TestFixture] + public sealed class CreateVoteProofOfPossession + { + [Test] + public void PinnedVoteVector_BindsAlpenglowVoteAccountAndCompressedPublicKey() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial); + + // Act + var proof = keypair.CreateVoteProofOfPossession(VoteAccount); + + // Assert + Convert.ToHexString(proof.ToBytes()).Should().Be(ExpectedVoteProof); + keypair.PublicKey.VerifyVoteProofOfPossession(proof, VoteAccount).Should().BeTrue(); + BlsProofOfPossession.Parse(proof.ToString()).Should().Be(proof); + BlsProofOfPossession.TryParse("not base64", out _).Should().BeFalse(); + + var otherVoteAccount = new PublicKey(Enumerable.Repeat((byte)42, PublicKey.Length).ToArray()); + keypair.PublicKey.VerifyVoteProofOfPossession(proof, otherVoteAccount).Should().BeFalse(); + } + } + + [TestFixture] + public sealed class CreateProofOfPossession + { + [Test] + public void CustomPayloadAndPublicKeyAreBothBound() + { + // Arrange + using var keypair = BlsKeypair.Derive(InputKeyMaterial); + using var otherKeypair = BlsKeypair.Derive(Enumerable.Range(1, 32).Select(value => (byte)value).ToArray()); + + // Act + var proof = keypair.CreateProofOfPossession("payload"u8); + + // Assert + keypair.PublicKey.VerifyProofOfPossession(proof, "payload"u8).Should().BeTrue(); + keypair.PublicKey.VerifyProofOfPossession(proof, "other"u8).Should().BeFalse(); + otherKeypair.PublicKey.VerifyProofOfPossession(proof, "payload"u8).Should().BeFalse(); + } + } + + [TestFixture] + public sealed class Dispose + { + [Test] + public async Task RacingWithExports_ReturnsOnlyCoherentKeysOrObjectDisposed() + { + // Arrange + var keypair = BlsKeypair.Derive(InputKeyMaterial); + var expectedPublicKey = keypair.PublicKey; + var exported = new ConcurrentBag(); + using var ready = new CountdownEvent(4); + using var start = new ManualResetEventSlim(); + var workers = Enumerable.Range(0, 4).Select(_ => Task.Run(() => + { + exported.Add(keypair.ToBytes()); + ready.Signal(); + start.Wait(); + for (var i = 0; i < 8; i++) + { + try + { + exported.Add(keypair.ToBytes()); + } + catch (ObjectDisposedException) + { + break; + } + } + })).ToArray(); + + ready.Wait(); + var dispose = Task.Run(() => + { + start.Wait(); + keypair.Dispose(); + }); + + // Act + start.Set(); + await Task.WhenAll(workers.Append(dispose)); + + // Assert + try + { + exported.Should().NotBeEmpty(); + foreach (var bytes in exported) + { + using var imported = BlsKeypair.FromBytes(bytes); + imported.PublicKey.Should().Be(expectedPublicKey); + } + } + finally + { + foreach (var bytes in exported) + CryptographicOperations.ZeroMemory(bytes); + keypair.Dispose(); + } + } + + [Test] + public void SecretOperationsThrowAfterDisposeWhilePublicKeyRemainsUsable() + { + // Arrange + var keypair = BlsKeypair.Derive(InputKeyMaterial); + var publicKey = keypair.PublicKey; + keypair.Dispose(); + + // Act + Action sign = () => _ = keypair.Sign([]); + Action proof = () => _ = keypair.CreateProofOfPossession([]); + Action export = () => _ = keypair.ToSecretKeyBytes(); + Action jsonExport = () => _ = keypair.ToJsonUtf8Bytes(); + + // Assert + sign.Should().Throw(); + proof.Should().Throw(); + export.Should().Throw(); + jsonExport.Should().Throw(); + publicKey.ToBytes().Should().HaveCount(BlsPublicKey.Length); + } + } +} diff --git a/tests/SolSharp.Wallet.Tests/KeypairParsingTests.cs b/tests/SolSharp.Wallet.Tests/KeypairParsingTests.cs index c1718c8..356ca81 100644 --- a/tests/SolSharp.Wallet.Tests/KeypairParsingTests.cs +++ b/tests/SolSharp.Wallet.Tests/KeypairParsingTests.cs @@ -98,6 +98,7 @@ public void SecretKeyArray_DerivesPublicKeyAndSigns() [TestCase("[300]")] [TestCase("[-1]")] + [TestCase("[1,2,300]")] public void ValueOutOfByteRange_Throws(string json) { // Act @@ -292,6 +293,17 @@ public void NullEmptyOrWhitespace_Throws(string text) Action act = () => _ = Keypair.Parse(text); act.Should().Throw(); } + + [TestCase("1111111111")] + [TestCase("AQID")] + public void ValidEncodingWithWrongDecodedLength_Throws(string text) + { + // Act + Action act = () => _ = Keypair.Parse(text); + + // Assert + act.Should().Throw(); + } } [TestFixture] diff --git a/tests/SolSharp.Wallet.Tests/KeypairTests.cs b/tests/SolSharp.Wallet.Tests/KeypairTests.cs index 0c04740..4a22de5 100644 --- a/tests/SolSharp.Wallet.Tests/KeypairTests.cs +++ b/tests/SolSharp.Wallet.Tests/KeypairTests.cs @@ -1,4 +1,6 @@ +using System.Collections.Concurrent; using System.Runtime.CompilerServices; +using System.Security.Cryptography; using FluentAssertions; using NUnit.Framework; using SolSharp.Core.Primitives; @@ -95,6 +97,34 @@ public void SameMessage_IsDeterministic() } } + [TestFixture] + public sealed class SignSignature + { + [Test] + public void Rfc8032Test1_EmptyMessage_MatchesVector() + { + // Arrange + using var keypair = Keypair.FromSeed(Hex(Test1Seed)); + + // Act & Assert + keypair.SignSignature([]).ToBytes().Should().Equal(Hex(Test1Signature)); + } + + [Test] + public void AfterDispose_Throws() + { + // Arrange + var keypair = Keypair.FromSeed(Hex(Test1Seed)); + keypair.Dispose(); + + // Act + Action act = () => keypair.SignSignature([]); + + // Assert + act.Should().Throw(); + } + } + [TestFixture] public sealed class FromSecretKey { @@ -165,9 +195,154 @@ public void SignedMessage_Is64Bytes() } } + [TestFixture] + public sealed class Export + { + [Test] + public void BytesSeedBase58AndJson_RoundTripExactUpstreamLayout() + { + // Arrange + var expected = Hex(Test1Seed + Test1PublicKey); + using var keypair = Keypair.FromSeed(Hex(Test1Seed)); + + // Act + var bytes = keypair.ToBytes(); + var seed = keypair.ToSeedBytes(); + var base58 = keypair.ToBase58String(); + var json = keypair.ToJsonArray(); + byte[]? fromBase58Bytes = null; + byte[]? fromJsonBytes = null; + + try + { + // Assert + bytes.Should().Equal(expected); + seed.Should().Equal(Hex(Test1Seed)); + using var fromBase58 = Keypair.FromBase58String(base58); + using var fromJson = Keypair.FromJsonArray(json); + fromBase58Bytes = fromBase58.ToBytes(); + fromJsonBytes = fromJson.ToBytes(); + fromBase58Bytes.Should().Equal(expected); + fromJsonBytes.Should().Equal(expected); + } + finally + { + CryptographicOperations.ZeroMemory(expected); + CryptographicOperations.ZeroMemory(bytes); + CryptographicOperations.ZeroMemory(seed); + if (fromBase58Bytes is not null) + CryptographicOperations.ZeroMemory(fromBase58Bytes); + if (fromJsonBytes is not null) + CryptographicOperations.ZeroMemory(fromJsonBytes); + } + } + + [Test] + public void ReturnedArraysAreIndependentCopies() + { + // Arrange + using var keypair = Keypair.FromSeed(Hex(Test1Seed)); + var exported = keypair.ToBytes(); + var seed = keypair.ToSeedBytes(); + byte[]? afterMutation = null; + + // Act + exported[0] ^= byte.MaxValue; + seed[0] ^= byte.MaxValue; + + try + { + // Assert + afterMutation = keypair.ToBytes(); + afterMutation.Should().Equal(Hex(Test1Seed + Test1PublicKey)); + } + finally + { + CryptographicOperations.ZeroMemory(exported); + CryptographicOperations.ZeroMemory(seed); + if (afterMutation is not null) + CryptographicOperations.ZeroMemory(afterMutation); + } + } + + [Test] + public void AfterDispose_ThrowsForEveryExport() + { + // Arrange + var keypair = Keypair.FromSeed(Hex(Test1Seed)); + keypair.Dispose(); + var toBytes = keypair.ToBytes; + var toSeedBytes = keypair.ToSeedBytes; + var toBase58 = keypair.ToBase58String; + var toJson = keypair.ToJsonArray; + + // Act & Assert + toBytes.Should().Throw(); + toSeedBytes.Should().Throw(); + toBase58.Should().Throw(); + toJson.Should().Throw(); + } + } + [TestFixture] public sealed class Dispose { + [Test] + public async Task RacingWithExports_ReturnsOnlyCoherentKeysOrObjectDisposed() + { + // Arrange + var keypair = Keypair.FromSeed(Hex(Test1Seed)); + var expectedPublicKey = keypair.PublicKey; + var exported = new ConcurrentBag(); + using var ready = new CountdownEvent(8); + using var start = new ManualResetEventSlim(); + var workers = Enumerable.Range(0, 8).Select(_ => Task.Run(() => + { + exported.Add(keypair.ToBytes()); + ready.Signal(); + start.Wait(); + for (var i = 0; i < 128; i++) + { + try + { + exported.Add(keypair.ToBytes()); + } + catch (ObjectDisposedException) + { + break; + } + } + })).ToArray(); + + ready.Wait(); + var dispose = Task.Run(() => + { + start.Wait(); + keypair.Dispose(); + }); + + // Act + start.Set(); + await Task.WhenAll(workers.Append(dispose)); + + // Assert + try + { + exported.Should().NotBeEmpty(); + foreach (var bytes in exported) + { + using var imported = Keypair.FromSecretKey(bytes); + imported.PublicKey.Should().Be(expectedPublicKey); + } + } + finally + { + foreach (var bytes in exported) + CryptographicOperations.ZeroMemory(bytes); + keypair.Dispose(); + } + } + [Test] public void SignAfterDispose_Throws() { @@ -190,7 +365,7 @@ public void CalledTwice_DoesNotThrow() keypair.Dispose(); // Act - Action act = keypair.Dispose; + var act = keypair.Dispose; // Assert act.Should().NotThrow(); diff --git a/tests/SolSharp.Wallet.Tests/NullSignerTests.cs b/tests/SolSharp.Wallet.Tests/NullSignerTests.cs new file mode 100644 index 0000000..73c2ae4 --- /dev/null +++ b/tests/SolSharp.Wallet.Tests/NullSignerTests.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Wallet.Tests; + +public static class NullSignerTests +{ + [TestFixture] + public sealed class Sign + { + [Test] + public void ReturnsSolanaZeroSignaturePlaceholder() + { + // Arrange + var publicKey = new PublicKey(Enumerable.Repeat((byte)1, PublicKey.Length).ToArray()); + var signer = new NullSigner(publicKey); + + // Act + var signature = signer.Sign("ignored"u8); + + // Assert + signer.PublicKey.Should().Be(publicKey); + signature.Should().HaveCount(Signature.Length).And.OnlyContain(value => value == 0); + } + + [Test] + public void EachCallReturnsIndependentArray() + { + // Arrange + var signer = new NullSigner(default); + var first = signer.Sign([]); + + // Act + first[0] = 1; + + // Assert + signer.Sign([]).Should().OnlyContain(value => value == 0); + } + } +} diff --git a/tests/SolSharp.Wallet.Tests/OffchainMessageTests.cs b/tests/SolSharp.Wallet.Tests/OffchainMessageTests.cs new file mode 100644 index 0000000..3459c92 --- /dev/null +++ b/tests/SolSharp.Wallet.Tests/OffchainMessageTests.cs @@ -0,0 +1,148 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Wallet.Tests; + +public static class OffchainMessageTests +{ + private static readonly byte[] UpstreamAsciiWire = Convert.FromHexString( + "FF736F6C616E61206F6666636861696E00000C0054657374204D657373616765"); + + private static readonly byte[] UpstreamUtf8Wire = Convert.FromHexString( + "FF736F6C616E61206F6666636861696E00012300D0A2D0B5D181D182D0BED0B2" + + "D0BED0B520D181D0BED0BED0B1D189D0B5D0BDD0B8D0B5"); + + private const string UpstreamAsciiHash = "HG5JydBGjtjTfD3sSn21ys5NTWPpXzmqifiGC2BVUjkD"; + private const string UpstreamUtf8Hash = "6GXTveatZQLexkX4WeTpJ3E7uk1UojRXpKp43c4ArSun"; + + [TestFixture] + public sealed class Create + { + [Test] + public void PrintableAscii_UsesRestrictedFormatAndExactUpstreamVector() + { + // Act + var message = OffchainMessage.Create("Test Message"); + + // Assert + message.Version.Should().Be(0); + message.Format.Should().Be(OffchainMessageFormat.RestrictedAscii); + message.MessageLength.Should().Be(12); + message.Serialize().Should().Equal(UpstreamAsciiWire); + message.ComputeHash().Should().Be(Hash.Parse(UpstreamAsciiHash)); + } + + [Test] + public void Utf8_UsesLimitedFormatAndExactUpstreamHash() + { + // Act + var message = OffchainMessage.Create("Тестовое сообщение"); + + // Assert + message.Format.Should().Be(OffchainMessageFormat.LimitedUtf8); + message.Serialize().Should().Equal(UpstreamUtf8Wire); + message.ComputeHash().Should().Be(Hash.Parse(UpstreamUtf8Hash)); + } + + [Test] + public void AboveLedgerLimit_UsesExtendedUtf8() + { + // Arrange + var bytes = Enumerable.Repeat((byte)'a', OffchainMessage.MaxLedgerMessageLength + 1).ToArray(); + + // Act & Assert + OffchainMessage.Create(bytes).Format.Should().Be(OffchainMessageFormat.ExtendedUtf8); + } + + [Test] + public void ControlCharacter_UsesLimitedUtf8() + => OffchainMessage.Create("line\n").Format.Should().Be(OffchainMessageFormat.LimitedUtf8); + + [Test] + public void EmptyOrInvalidUtf8_Throws() + { + // Act + var empty = () => _ = OffchainMessage.Create((byte[])[]); + var invalid = () => _ = OffchainMessage.Create([0xFF]); + + // Assert + empty.Should().Throw(); + invalid.Should().Throw(); + } + + [Test] + public void UnsupportedVersionOrOversizedPayload_Throws() + { + // Act + var version = () => _ = OffchainMessage.Create(1, "x"u8); + var oversized = () => _ = OffchainMessage.Create(new byte[OffchainMessage.MaxMessageLength + 1]); + + // Assert + version.Should().Throw(); + oversized.Should().Throw(); + } + } + + [TestFixture] + public sealed class Deserialize + { + [Test] + public void UpstreamVector_RoundTripsAndCopiesPayload() + { + // Act + var message = OffchainMessage.Deserialize(UpstreamAsciiWire); + var payload = message.ToMessageBytes(); + payload[0] ^= byte.MaxValue; + + // Assert + message.Serialize().Should().Equal(UpstreamAsciiWire); + message.ToMessageBytes().Should().Equal("Test Message"u8.ToArray()); + message.Should().Be(OffchainMessage.Create("Test Message")); + } + + [Test] + public void InvalidDomainVersionLengthFormatOrPayload_Throws() + { + // Arrange + var invalidDomain = UpstreamAsciiWire.ToArray(); + invalidDomain[0] = 0; + var invalidVersion = UpstreamAsciiWire.ToArray(); + invalidVersion[16] = 1; + var invalidLength = UpstreamAsciiWire.ToArray(); + invalidLength[18] = 11; + var invalidFormat = UpstreamAsciiWire.ToArray(); + invalidFormat[17] = 3; + var invalidPayload = UpstreamAsciiWire.ToArray(); + invalidPayload[20] = 0; + + // Act & Assert + ((Action)(() => _ = OffchainMessage.Deserialize(invalidDomain))).Should().Throw(); + ((Action)(() => _ = OffchainMessage.Deserialize(invalidVersion))).Should().Throw(); + ((Action)(() => _ = OffchainMessage.Deserialize(invalidLength))).Should().Throw(); + ((Action)(() => _ = OffchainMessage.Deserialize(invalidFormat))).Should().Throw(); + ((Action)(() => _ = OffchainMessage.Deserialize(invalidPayload))).Should().Throw(); + } + } + + [TestFixture] + public sealed class SignAndVerify + { + [Test] + public void ExactSerializedMessage_IsSignedAndStrictlyVerified() + { + // Arrange + using var keypair = Keypair.FromSeed(Convert.FromHexString( + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")); + var message = OffchainMessage.Create("Test Message"); + + // Act + var signature = message.Sign(keypair); + + // Assert + signature.Should().Be(keypair.SignSignature(UpstreamAsciiWire)); + message.Verify(keypair.PublicKey, signature).Should().BeTrue(); + OffchainMessage.Create("Other Message").Verify(keypair.PublicKey, signature).Should().BeFalse(); + } + } +} diff --git a/tests/SolSharp.Wallet.Tests/PresignerTests.cs b/tests/SolSharp.Wallet.Tests/PresignerTests.cs new file mode 100644 index 0000000..6e88883 --- /dev/null +++ b/tests/SolSharp.Wallet.Tests/PresignerTests.cs @@ -0,0 +1,62 @@ +using System.Security.Cryptography; +using FluentAssertions; +using NUnit.Framework; + +namespace SolSharp.Wallet.Tests; + +public static class PresignerTests +{ + [TestFixture] + public sealed class Sign + { + [Test] + public void UpstreamContract_VerifiesAndReturnsExternalSignature() + { + // Arrange + using var keypair = Keypair.FromSeed(new byte[Keypair.SeedLength]); + ReadOnlySpan message = [1]; + var signature = keypair.SignSignature(message); + var presigner = new Presigner(keypair.PublicKey, signature); + + // Act + var result = presigner.Sign(message); + + // Assert + presigner.PublicKey.Should().Be(keypair.PublicKey); + presigner.Signature.Should().Be(signature); + result.Should().Equal(signature.ToBytes()); + } + + [Test] + public void DifferentMessage_ThrowsCryptographicException() + { + // Arrange + using var keypair = Keypair.FromSeed(new byte[Keypair.SeedLength]); + var presigner = new Presigner(keypair.PublicKey, keypair.SignSignature([1])); + + // Act + Action act = () => presigner.Sign([2]); + + // Assert + act.Should().Throw(); + } + + [Test] + public void ReturnedArrayCannotMutateStoredSignature() + { + // Arrange + using var keypair = Keypair.FromSeed(new byte[Keypair.SeedLength]); + ReadOnlySpan message = [1]; + var expected = keypair.SignSignature(message); + var presigner = new Presigner(keypair.PublicKey, expected); + var returned = presigner.Sign(message); + + // Act + returned[0] ^= byte.MaxValue; + + // Assert + presigner.Signature.Should().Be(expected); + presigner.Sign(message).Should().Equal(expected.ToBytes()); + } + } +} diff --git a/tests/SolSharp.Wallet.Tests/PublicKeyExtensionsTests.cs b/tests/SolSharp.Wallet.Tests/PublicKeyExtensionsTests.cs index 1f261a6..96d0bd9 100644 --- a/tests/SolSharp.Wallet.Tests/PublicKeyExtensionsTests.cs +++ b/tests/SolSharp.Wallet.Tests/PublicKeyExtensionsTests.cs @@ -28,22 +28,16 @@ public static class PublicKeyExtensionsTests public sealed class Verify { [Test] - public void Rfc8032Test1_EmptyMessage_ReturnsTrue() - { - Key(Test1PublicKey).Verify([], Hex(Test1Signature)).Should().BeTrue(); - } + public void Rfc8032Test1_EmptyMessage_ReturnsTrue() => Key(Test1PublicKey).Verify([], Hex(Test1Signature)).Should().BeTrue(); [Test] - public void Rfc8032Test2_ReturnsTrue() - { - Key(Test2PublicKey).Verify(Hex(Test2Message), Hex(Test2Signature)).Should().BeTrue(); - } + public void TypedSignature_Rfc8032Test1_ReturnsTrue() => Key(Test1PublicKey).Verify([], new Signature(Hex(Test1Signature))).Should().BeTrue(); [Test] - public void TamperedMessage_ReturnsFalse() - { - Key(Test2PublicKey).Verify(Hex("73"), Hex(Test2Signature)).Should().BeFalse(); - } + public void Rfc8032Test2_ReturnsTrue() => Key(Test2PublicKey).Verify(Hex(Test2Message), Hex(Test2Signature)).Should().BeTrue(); + + [Test] + public void TamperedMessage_ReturnsFalse() => Key(Test2PublicKey).Verify(Hex("73"), Hex(Test2Signature)).Should().BeFalse(); [Test] public void TamperedSignature_ReturnsFalse() @@ -57,17 +51,55 @@ public void TamperedSignature_ReturnsFalse() } [Test] - public void WrongKey_ReturnsFalse() - { - Key(Test1PublicKey).Verify(Hex(Test2Message), Hex(Test2Signature)).Should().BeFalse(); - } + public void WrongKey_ReturnsFalse() => Key(Test1PublicKey).Verify(Hex(Test2Message), Hex(Test2Signature)).Should().BeFalse(); [TestCase(0)] [TestCase(63)] [TestCase(65)] - public void WrongLengthSignature_ReturnsFalse(int length) + public void WrongLengthSignature_ReturnsFalse(int length) => Key(Test1PublicKey).Verify([], new byte[length]).Should().BeFalse(); + + // C2SP CCTV Ed25519 test 5, also pinned by Agave's strict-verification regression test: + // R is a low-order point, so accepting this signature would make verification malleable. + [Test] + public void LowOrderRSignature_ReturnsFalse() { - Key(Test1PublicKey).Verify([], new byte[length]).Should().BeFalse(); + // Arrange + var publicKey = Key("10eb7c3acfb2bed3e0d6ab89bf5a3d6afddd1176ce4812e38d9fd485058fdb1f"); + var signature = Hex( + "0000000000000000000000000000000000000000000000000000000000000000" + + "9472a69cd9a701a50d130ed52189e2455b23767db52cacb8716fb896ffeeac09"); + + // Act & Assert + publicKey.Verify("ed25519vectors 3"u8, signature).Should().BeFalse(); + } + + // C2SP CCTV Ed25519 vector 3 has a small-order public key and an otherwise non-small-order R. + [Test] + public void LowOrderPublicKey_ReturnsFalse() + { + // Arrange + var publicKey = Key("0000000000000000000000000000000000000000000000000000000000000000"); + var signature = Hex( + "36684ea91032ba5b1dbab2d02f4debc74c3327f2b3802e2e4d371aa42b12b56b" + + "05ba9a796274d80437afa36f1236563f2f3b0aa84cecddc3d20914615ba4fe02"); + + // Act & Assert + publicKey.Verify("ed25519vectors 3"u8, signature).Should().BeFalse(); + } + + // C2SP CCTV Ed25519 vector 7 contains low-order components in A and R, but neither point + // is itself small-order. Solana strict verification accepts it; a full-subgroup check would not. + [Test] + public void MixedTorsionPoints_ReturnsTrue() + { + // Arrange + var publicKey = Key("10eb7c3acfb2bed3e0d6ab89bf5a3d6afddd1176ce4812e38d9fd485058fdb1f"); + var signature = Hex( + "36684ea91032ba5b1dbab2d02f4debc74c3327f2b3802e2e4d371aa42b12b56b" + + "bbfd00bd9c259d8d222d15e67a3d8228585050dbb9b9585be20d8fadc721da03"); + + // Act & Assert + publicKey.Verify("ed25519vectors"u8, signature).Should().BeTrue(); } [Test] diff --git a/tests/SolSharp.Wallet.Tests/SignatureTests.cs b/tests/SolSharp.Wallet.Tests/SignatureTests.cs new file mode 100644 index 0000000..242740c --- /dev/null +++ b/tests/SolSharp.Wallet.Tests/SignatureTests.cs @@ -0,0 +1,192 @@ +using FluentAssertions; +using NUnit.Framework; +using SolSharp.Core.Primitives; + +namespace SolSharp.Wallet.Tests; + +public static class SignatureTests +{ + // solana-sdk/signature/src/lib.rs test_signature_fromstr vector. + private const string UpstreamBase58 = + "34UR3rLRtnsQVHNQ49AtUzYP5mLWsvoEBPYMGa1dmSHvg6pZup8ysqtM5LEg2vbcGfi91Upu2JkLyw3uRm7Y1fqX"; + + private static readonly byte[] UpstreamBytes = Convert.FromHexString( + "67075860CB8CBF2FE7251EDC3D235D70E102050B9E69F69385406DFC77496CF8" + + "A7F0A012DE03013033435E135B6CE37E6419D4875A3C3D4EBA68163AF24A9406"); + + private const string RfcPublicKey = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; + + private const string RfcSignature = + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155" + + "5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"; + + private static byte[] Hex(string hex) => Convert.FromHexString(hex); + + [TestFixture] + public sealed class Construct + { + [Test] + public void UpstreamKnownVector_RoundTripsBytesAndBase58() + { + // Act + var signature = new Signature(UpstreamBytes); + + // Assert + signature.ToBytes().Should().Equal(UpstreamBytes); + signature.ToString().Should().Be(UpstreamBase58); + } + + [TestCase(0)] + [TestCase(63)] + [TestCase(65)] + public void WrongLength_Throws(int length) + { + // Act + Action act = () => _ = new Signature(new byte[length]); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class Parse + { + [Test] + public void UpstreamKnownVector_RoundTripsToSameBytes() + => Signature.Parse(UpstreamBase58).ToBytes().Should().Equal(UpstreamBytes); + + [TestCase("0")] + [TestCase("abc")] + public void Invalid_Throws(string input) + { + // Act + Action act = () => Signature.Parse(input); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class TryParse + { + [Test] + public void ValidBase58_ReturnsTrueAndSignature() + { + // Act + var parsed = Signature.TryParse(UpstreamBase58, out var signature); + + // Assert + parsed.Should().BeTrue(); + signature.ToBytes().Should().Equal(UpstreamBytes); + } + + [TestCase("0")] + [TestCase("abc")] + [TestCase(null)] + [TestCase("")] + public void Invalid_ReturnsFalseAndDefault(string? input) + { + // Act + var parsed = Signature.TryParse(input, out var signature); + + // Assert + parsed.Should().BeFalse(); + signature.Should().Be(default(Signature)); + } + } + + [TestFixture] + public sealed class Equality + { + [Test] + public void SameBytes_AreEqual() + { + // Arrange + var a = Signature.Parse(UpstreamBase58); + var b = new Signature(a.ToBytes()); + + // Act & Assert + a.Should().Be(b); + (a == b).Should().BeTrue(); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Test] + public void DifferentBytes_AreNotEqual() + { + // Arrange + var a = Signature.Parse(UpstreamBase58); + var b = default(Signature); + + // Act & Assert + a.Should().NotBe(b); + (a != b).Should().BeTrue(); + } + + [Test] + public void Default_EqualsAllZeroSignature() + => default(Signature).Should().Be(new Signature(new byte[Signature.Length])); + } + + [TestFixture] + public sealed class Bytes + { + [Test] + public void CopyTo_WritesAllBytes() + { + // Arrange + var signature = Signature.Parse(UpstreamBase58); + var destination = new byte[Signature.Length]; + + // Act + signature.CopyTo(destination); + + // Assert + destination.Should().Equal(UpstreamBytes); + } + + [Test] + public void CopyTo_DestinationTooSmall_Throws() + { + // Arrange + var signature = Signature.Parse(UpstreamBase58); + + // Act + var act = () => signature.CopyTo(new byte[Signature.Length - 1]); + + // Assert + act.Should().Throw(); + } + } + + [TestFixture] + public sealed class Verify + { + [Test] + public void Rfc8032Vector_UsesStrictVerification() + { + // Arrange + var signature = new Signature(Hex(RfcSignature)); + var publicKey = new PublicKey(Hex(RfcPublicKey)); + + // Act & Assert + signature.Verify(publicKey, []).Should().BeTrue(); + signature.Verify(publicKey, "tampered"u8).Should().BeFalse(); + } + + [Test] + public void SmallOrderR_ReturnsFalse() + { + // Arrange: C2SP CCTV vector 5, pinned by Agave's strict-verification regression test. + var publicKey = new PublicKey(Hex("10eb7c3acfb2bed3e0d6ab89bf5a3d6afddd1176ce4812e38d9fd485058fdb1f")); + var signature = new Signature(Hex( + "0000000000000000000000000000000000000000000000000000000000000000" + + "9472a69cd9a701a50d130ed52189e2455b23767db52cacb8716fb896ffeeac09")); + + // Act & Assert + signature.Verify(publicKey, "ed25519vectors 3"u8).Should().BeFalse(); + } + } +} diff --git a/tests/SolSharp.Wallet.Tests/Slip10Tests.cs b/tests/SolSharp.Wallet.Tests/Slip10Tests.cs index da327cd..79789e8 100644 --- a/tests/SolSharp.Wallet.Tests/Slip10Tests.cs +++ b/tests/SolSharp.Wallet.Tests/Slip10Tests.cs @@ -51,6 +51,16 @@ public void NonHardenedSegment_Throws() act.Should().Throw(); } + [TestCase("m/+1'")] + [TestCase("m/ 1'")] + [TestCase("m/1 '")] + public void NonCanonicalNumericSegment_Throws(string path) + { + // Act & Assert + Action act = () => _ = Slip10.DeriveEd25519(new byte[16], path); + act.Should().Throw(); + } + [Test] public void PathNotStartingWithMaster_Throws() {