Activeledger.SDK
2.6.0
dotnet add package Activeledger.SDK --version 2.6.0
NuGet\Install-Package Activeledger.SDK -Version 2.6.0
<PackageReference Include="Activeledger.SDK" Version="2.6.0" />
<PackageVersion Include="Activeledger.SDK" Version="2.6.0" />
<PackageReference Include="Activeledger.SDK" />
paket add Activeledger.SDK --version 2.6.0
#r "nuget: Activeledger.SDK, 2.6.0"
#:package Activeledger.SDK@2.6.0
#addin nuget:?package=Activeledger.SDK&version=2.6.0
#tool nuget:?package=Activeledger.SDK&version=2.6.0
<a href="https://activeledger.io/"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/activeledger/activeledger/master/docs/assets/Asset-23-dark.png"> <img src="https://raw.githubusercontent.com/activeledger/activeledger/master/docs/assets/Asset-23.png" alt="Activeledger" width="300"/> </picture> </a>
Activeledger SDK for C#
Documentation: SDK guide | All SDKs | Activeledger guide
Build, sign and submit Activeledger transactions from .NET, with support for post-quantum identities.
- ML-DSA-65 and Falcon-512 post-quantum identities
- secp256k1 for small payloads, hardware keys and existing identities
- Canonical JSON that reproduces the exact bytes the ledger signs
- Server-sent event subscriptions
- One dependency (BouncyCastle), pure managed code, no native libraries
netstandard2.0andnet8.0, so .NET Framework, Unity, Mono and modern .NET all work
Install
dotnet add package Activeledger.SDK
Targets netstandard2.0 and net8.0, so .NET Framework and Unity consumers
work as well as modern ones.
Verified: restores from NuGet and derives keys for all three schemes.
Quick start
using Activeledger;
using var client = new ActiveledgerClient("http://localhost:5260");
// A post-quantum identity
var key = KeyPair.Generate(KeyType.MlDsa65);
var identity = await client.OnboardAsync(key);
Console.WriteLine(identity.StreamId);
// A transaction signed by it
var tx = Transaction.Builder()
.Namespace("default")
.Contract("mycontract")
.Input(identity.StreamId, identity.Signer,
new JsonObject().Set("amount", 100))
.Output("someotherstream")
.Build();
var response = await client.SubmitAsync(tx);
if (!response.Committed)
{
// A ledger rejection arrives as HTTP 200 with errors in the body. Committed
// is also false for a non-2xx status, a body that is not a JSON object
// (an empty body, an HTML 502) or an object with no $summary; Errors says why. The getters never throw.
Console.WriteLine(string.Join(", ", response.Errors));
}
Seeds and recovery phrases
var key = KeyPair.FromSeed(KeyType.MlDsa65, seed); // 32 bytes
var key = KeyPair.FromSeed(KeyType.Falcon512, seed); // 48 bytes
var key = KeyPair.FromPhrase(KeyType.MlDsa65, phrase); // BIP-39
var key = KeyPair.FromPhrase(KeyType.Secp256k1, phrase, "pass");
The same seed gives the same identity in every Activeledger SDK, which is what makes a seed the portable private-key format — it is how a private key moves between languages. It matters most for PHP, whose ML-DSA-65 private key is a 32-byte seed and which has no 4032-byte form at all.
A seed of the wrong length is refused, not padded: a padded seed is a different identity, not a malformed one.
For secp256k1 the seed is the private scalar, so it has to be a valid
one. A seed of zero, or one at or above the curve order, is refused rather
than reduced mod n — reducing produces a perfectly functional key belonging
to a different identity, and nothing downstream ever reports a problem.
The phrase is validated, wordlist and checksum. A mistyped phrase that is not checked does not fail; it derives a valid key for an identity nobody owns, and the only symptom is the ledger not recognising it.
KeyPair.FromLegacyPhrase recovers a phrase made by the older
@activeledger/sdk-bip39 package — recovery only, never for new keys.
Passphrases and Unicode
BIP-39 puts the passphrase through Unicode NFKD, so "caf\u00e9" and
"cafe\u0301" must give the same seed. .NET cannot normalise when
InvariantGlobalization is on (or ICU is missing): string.Normalize returns
non-ASCII text unchanged there. In that case RecoveryPhrase.ToSeed (and so
KeyPair.FromPhrase) throws PlatformNotSupportedException for any passphrase
containing a non-ASCII character, rather than derive a key that differs from
other hosts. ASCII passphrases work in every mode.
The derivation
| Type | Seed from the BIP-39 seed S |
|---|---|
secp256k1 |
HMAC-SHA512("Bitcoin seed", S)[0..32] |
ml-dsa-65 |
HKDF-SHA512(S, salt="", info="activeledger-seed-v1:ml-dsa-65", 32) |
falcon-512 |
HKDF-SHA512(S, salt="", info="activeledger-seed-v1:falcon-512", 48) |
secp256k1 deliberately does not use HKDF: the JavaScript SDK shipped that
derivation before the post-quantum types existed, so phrases are already in
use, and changing it would hand those users a different key for a phrase that
used to work.
One phrase can back all three identity types at once, since each derives its own seed.
BouncyCastle's PBKDF2 and HKDF are used rather than the framework's.
System.Security.Cryptography.HKDF does not exist on netstandard2.0, so the
framework route would derive different keys depending on which target a
consumer built against.
Key types
| Key type | Wire string | Public | Private | Signature | Encoding |
|---|---|---|---|---|---|
KeyType.MlDsa65 |
ml-dsa-65 |
1952 | 4032 | 3309 | base64 |
KeyType.Falcon512 |
falcon-512 |
897 | 1281 | 648–663, variable | base64 |
KeyType.Secp256k1 |
secp256k1 |
33 or 65 | 32 | 68–71, variable | 0x hex |
KeyType.Rsa |
rsa |
— | — | — | not implemented |
RSA is deliberately not implemented. KeyType.Rsa exists in the enum so
the ledger's wire string can be named, but KeyPair.Generate, Sign and
Verify throw NotSupportedException for it. RSA is the ledger's fallback
when a transaction omits type, which is precisely why this SDK always sends
the type explicitly.
Which to choose
Post-quantum if the identity must outlive a cryptographically relevant quantum computer. secp256k1 otherwise — it is dramatically cheaper, and every byte is stored on the ledger permanently and replicated to every node. Measured with this SDK:
| Transaction | secp256k1 | Falcon-512 | ML-DSA-65 |
|---|---|---|---|
| Onboard | 321 B | 2,230 B | 7,173 B |
| Transfer | 204 B | 984 B | 4,520 B |
secp256k1 is also what hardware wallets and most HSMs speak, and it is what existing Activeledger identities already use — so it is the only way to sign for an identity created before post-quantum support.
Note that phone secure enclaves (Apple Secure Enclave, Android StrongBox) use P-256, not secp256k1, and the ledger does not support P-256 at all.
var mldsa = KeyPair.Generate(KeyType.MlDsa65);
var falcon = KeyPair.Generate(KeyType.Falcon512);
var ec = KeyPair.Generate(KeyType.Secp256k1); // compressed
var ecFull = KeyPair.Generate(KeyType.Secp256k1, compressed: false); // uncompressed
// Exactly the encoding the ledger stores, whichever scheme it is
string pub = mldsa.PublicKey; // base64
string priv = mldsa.PrivateKey;
string ecPub = ec.PublicKey; // "0x02a1b2..."
// Round-trip a stored key
// Round-trip a stored key. Throws ArgumentException if the two halves do not
// belong together.
var restored = KeyPair.FromKeys(KeyType.MlDsa65, pub, priv);
// Verification only - no private key, and Sign() throws
var verifier = KeyPair.FromPublic(KeyType.MlDsa65, pub);
PublicKey and PrivateKey are deliberately not named for an encoding,
because it differs by scheme: base64 for the post-quantum keys, 0x-prefixed
hex for secp256k1. Whatever they return goes into the transaction verbatim.
Things about these keys worth knowing before they cost you an afternoon.
secp256k1 keys are hex with an 0x prefix, not base64. The prefix is part
of what the ledger stores, so this SDK requires it rather than tolerating its
absence — a hex string without it can decode as base64 into plausible-looking
bytes of the wrong length, which is the kind of mistake that surfaces as 1220.
secp256k1 public keys come in two lengths, and the ledger accepts both: 33
bytes compressed (0x02/0x03) and 65 uncompressed (0x04). This SDK
generates compressed by default because the key is stored forever. A length and
a point prefix that disagree is rejected by name.
secp256k1 private keys are left-padded to 32 bytes. A scalar with a leading zero byte occurs about once in 400 keys, and an unpadded key is a different value to anything reading it strictly.
secp256k1 signatures are DER, and DER length varies — 68 to 71 bytes
all occur (70 and 71 account for nearly all). A raw 64-byte r||s pair is not what the ledger expects.
secp256k1 signing is deterministic (RFC 6979) and always low-S, and this matters in both directions:
- Emitting low-S is not for the ledger, which accepts either. It is for
everything else:
@noble/curvesrejects high-S unless explicitly told not to, and it is the reference implementation for the JavaScript side; libsecp256k1 rejects it outright. A signer that emits high-S roughly half the time fails against those verifiers roughly half the time, which reads as flakiness rather than as a signature format problem. - Verifying deliberately does not enforce low-S, because the ledger verifies through OpenSSL, which neither normalises nor requires it. Roughly half of all ledger-produced signatures are high-S, and rejecting them would be the same bug in the opposite direction.
Because signing is deterministic, this SDK's signatures are byte-identical to
@noble/curves for the same key and message — checked against published
reference bytes on every test run, not merely verified.
Falcon signatures are not a fixed length. They vary between 648 and 663 bytes. Any buffer, column or assertion that assumes a constant size will work for a while and then fail on a signature that happens to compress differently.
Falcon keys carry a one-byte header — 0x09 on a public key, 0x59 on a
private key — which is why they are 897 and 1281 rather than 896 and 1280.
BouncyCastle strips it; this SDK adds it back, because the ledger stores the
headered form. A key that is 896 bytes has had its header removed somewhere,
and this SDK says so by name rather than reporting a generic length error.
The post-quantum schemes are hedged, so signing the same message twice produces different bytes. Never compare those for equality — verify them. secp256k1 is the exception: it is deterministic, so it can be compared, and this SDK does exactly that against the reference implementation.
Conformance is checked against the cross-language vectors published by the ledger repository: all 24 verify — 6 per post-quantum scheme, and 12 for secp256k1 covering both public key forms — and signatures produced here verify against the reference public keys. The secp256k1 vectors additionally publish the exact deterministic bytes a conforming signer must emit, and this SDK reproduces all 12.
Transactions
var tx = Transaction.Builder()
.Namespace("default")
.Contract("mycontract")
.Entry("transfer") // optional, omitted when unset
.Input(streamId, signer, payload) // repeat for multiple signers
.Output(otherStream, payload) // optional
.ReadOnly("label", someStreamId) // optional, see "Reading state"
.Build();
Every signer signs the same bytes: the canonical form of $tx. $sigs is
keyed by input stream id.
Onboarding is different in two ways that catch every port, so it has its own method:
var tx = Transaction.Onboard(key); // $selfsign: true, $sigs keyed by $i label
To inspect exactly what was signed — the fastest way to diagnose a rejected signature:
byte[] signed = tx.SignedBytes();
string json = tx.ToJson(); // the full envelope, as submitted
Reading state
There is no read API. A node's storage service listens only on that node's own
host, so reading state is a transaction like any other: name the streams in
$r, and the contract hands values back with returnToRemote.
var tx = Transaction.Builder()
.Namespace("default")
.Contract("mycontract")
.Entry("read")
.Input(identity.StreamId, identity.Signer)
.ReadOnly("target", streamToRead)
.Build();
var response = await client.SubmitAsync(tx);
foreach (var value in response.Responses)
{
Console.WriteLine(value.GetProperty("balance").GetInt32());
}
response.NewStreams gives the ids of any streams the transaction created.
Events (SSE) - deprecated
SubscribeAsync, SubscribeToActivityAsync and SubscribeToContractEventsAsync are deprecated and will be removed in the next major version.
Events are no longer served by ActiveCore, which is itself deprecated and
should not be used. A node serves contract events from its own storage
service at http://localhost:<storage port>/activeledgerevents/events, and
that service must never be reachable beyond the node's host - so a client
SDK has nothing it should connect to.
To react to events, run your own server-sent events listener on the node's
host and relay what your application needs through your own backend. Each
event is an SSE frame whose id is <milliseconds>-<counter>,<umid> and
whose data is {"name", "data", "phase", "contract"}.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- BouncyCastle.Cryptography (>= 2.7.0)
- Microsoft.Bcl.AsyncInterfaces (>= 8.0.0)
- System.Text.Json (>= 8.0.5)
-
net8.0
- BouncyCastle.Cryptography (>= 2.7.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.