Skip to content

Repository files navigation

Crypto Chief .NET SDK — Crypto Processing API Client

NuGet Downloads SDK Docs License: MIT

Crypto Chief .NET SDK is the official C#/.NET client library for the Crypto Chief crypto processing API — a unified crypto payment gateway for accepting crypto payments, sending crypto payouts (single and mass), signing on-chain transactions, managing wallets, and verifying webhooks across Ethereum, Tron, TON, Solana, Bitcoin and 20+ more blockchains.

Drop it into any ASP.NET Core, Worker Service, console, or function app to add cryptocurrency payment processing — stablecoin (USDT / USDC) payouts, pay-ins, swaps, and smart-contract calls — with typed record requests, BigInteger amounts, Task<T> async, CancellationToken cooperation, and first-class IHttpClientFactory integration.

  • One-line setup; thread-safe CryptoChiefClient ready for the DI container.
  • Typed record request/response DTOs for every documented endpoint.
  • Contract calls without hand-encoded calldata — Solidity ABI for EVM and TRON, Anchor + Borsh for Solana, Jetton / NFT / comment helpers for TON.
  • Local RSA decryption of generated wallet private keys (opt-in).
  • Stable error codes via a typed CryptoChiefApiException, automatic retry on 5xx + transport faults with exponential-with-jitter backoff.
  • Arbitrary-precision amounts via System.Numerics.BigInteger — no double, ever.
  • Webhook verification + typed events (PayoutWebhookEvent, TransactionWebhookEvent, PayInWebhookEvent, StaticDepositWebhookEvent).
  • Polling helpers: await client.WaitForPayoutAsync(uuid) blocks until terminal.
  • Targets .NET 8.0 (LTS) and .NET 6.0 (LTS).

Install

dotnet add package CryptoChief.Processing

Quick start

using CryptoChief.Processing;
using CryptoChief.Processing.Chains;
using CryptoChief.Processing.Models;

var client = new CryptoChiefClient("MERCHANT_ID", "API_KEY");

var estimate = await client.Payouts.EstimateAsync(new EstimatePayoutRequest
{
    Network   = Chain.EthSepolia,
    Coin      = "ETH",
    Amount    = "0.0001",
    ToAddress = "0xRecipient...",
});
Console.WriteLine($"recipient receives {estimate.AmountToReceive}");

Both credentials come from the dashboard → Integration tab. The API key is the signing secret — keep it server-side.

Dependency injection (ASP.NET Core / Worker)

using Microsoft.Extensions.DependencyInjection;
using CryptoChief.Processing;

builder.Services.AddCryptoChief(o =>
{
    o.MerchantId = builder.Configuration["CryptoChief:MerchantId"]!;
    o.ApiKey     = builder.Configuration["CryptoChief:ApiKey"]!;
    o.LoadRsaPrivateKeyFromFile("rsa_private.pem"); // optional
});

Or bind from IConfiguration:

builder.Services.AddCryptoChief(builder.Configuration.GetSection("CryptoChief"));

The registration uses IHttpClientFactory, so the client respects HTTP connection pooling and any IHttpClientBuilder policies (Polly, logging, named handlers) you add downstream.

What you can do with it

Domain Service Key methods
Single payout (incl. auto-convert swap) client.Payouts EstimateAsync, ExecuteAsync, InfoAsync, HistoryAsync
Mass payout (up to 50 items) client.Payouts BatchEstimateAsync, BatchExecuteAsync
Two-phase sign / broadcast for arbitrary txs client.Transactions EstimateAsync, SignAsync, ExecuteAsync, InfoAsync, HistoryAsync
EVM / TRON contract calls (incl. ERC-20 / TRC-20) client.Transactions SignEvmCallAsync, SignTronCallAsync, Erc20TransferAsync
Solana programs client.Transactions SignAnchorCallAsync, SignSolanaCallAsync
TON contract calls (Jetton / NFT / text) client.Transactions JettonTransferAsync, NftTransferAsync, SendTonCommentAsync, SignTonCallAsync
Accept incoming payments client.PayIns CreateAsync, SelectAssetAsync, ResetAssetAsync, CancelAsync, InfoAsync, HistoryAsync
Wallet management + RSA decrypt client.Wallets GenerateAsync, ListAsync, InfoAsync, HistoryAsync, FreezeAsync, RebindMasterAsync, SetCallbackUrlAsync, SetLabelAsync, DecryptPrivateKey
Treasury sweeps client.Sweeps ForceAsync, HistoryAsync, WalletHistoryAsync, SettingsAsync, UpdateSettingsAsync
Withdrawals (read-only) client.Withdrawals InfoAsync, HistoryAsync
Static-deposit history client.StaticDeposits InfoAsync, HistoryAsync
On-chain queries client.Blockchain ContractsAvailableAsync, ContractsListAsync, BlockchainsListAsync, WalletBalanceAsync, TransactionStatusAsync
Fiat ↔ crypto rates and catalogues client.Currencies FiatToCryptoAsync, CryptoToFiatAsync, FiatsAsync, CryptosAsync
Credits balance & top-up (billing-exempt) client.Credits BalanceAsync, TopupAsync
TRON energy rental (billed to credits) client.Energy QuoteAsync, RentAsync, OrderAsync
Native coin purchase (billed to credits) client.Native QuoteAsync, BuyAsync, OrderAsync

Payout with confirmation

using CryptoChief.Processing.Errors;
using CryptoChief.Processing.Polling;

try
{
    var payout = await client.Payouts.ExecuteAsync(new ExecutePayoutRequest
    {
        OrderId     = "order-42",               // idempotency key — safe to retry
        UserId      = "u-7",
        Network     = Chain.EthSepolia,
        Coin        = "ETH",
        Amount      = "0.0001",
        ToAddress   = "0xRecipient...",
        UrlCallback = "https://your.app/webhooks/payout",
    });

    var final = await client.WaitForPayoutAsync(payout.Uuid);
    if (final.Succeeded)
        Console.WriteLine($"paid: tx={final.TxId}");
}
catch (CryptoChiefApiException ex) when (ex.Code == ErrorCodes.InsufficientFunds)
{
    // top up and try again
}

Confirmation fields on PayoutInfo (InfoAsync, ExecuteAsync, HistoryAsync), all optional:

Field Type Meaning
Sources[].Confirmations int? Confirmations of the source's transaction; absent until it is on chain.
ServiceOperations[].Confirmations int? Confirmations of a transaction the platform made for the payout, e.g. a gas top-up.
Confirmations int? Lowest count among the sources.
RequiredConfirmations int? Confirmations the network requires.

The payout is PayoutStatus.ConfirmCheck until every source reaches RequiredConfirmations, then paid. RequiredConfirmations may be absent; on paid with it present, Confirmations >= RequiredConfirmations. PayoutWebhookEvent carries the same two payout-level fields; each element of Sources and ServiceOperations carries confirmations once its transaction is on chain.

WaitForPayoutAsync without options waits 90 minutes (PollOptions.PayoutTimeout). With PollOptions, every WaitFor* method waits Timeout, 10 minutes by default; for a payout set Timeout = PollOptions.PayoutTimeout. A TimeoutException means the object is not finished yet; its last state is in ex.Data["LastSnapshot"].

Two-phase sign + execute

Transactions.SignAsync builds and signs a transaction without broadcasting. The TTL of the signed reservation varies by chain (EVM 10 m, UTXO 15 m, TRON 45 s, Solana 60 s, XRP 90 s, TON 300 s) — call ExecuteAsync before it expires.

using CryptoChief.Processing.Amounts;
using CryptoChief.Processing.Models;

var wei = Amount.HumanToBase("0.0001", 18);

var signed = await client.Transactions.SignAsync(new SignTransactionRequest
{
    Network     = Chain.EthSepolia,
    FromAddress = "0xYourWallet...",
    Type        = TxType.Native,
    ToAddress   = "0xRecipient...",
    Value       = wei.ToString(), // base units (wei)
    UrlCallback = "https://your.app/webhooks/transaction",
});

await client.Transactions.ExecuteAsync(new ExecuteTransactionRequest { Uuid = signed.Uuid });

TransactionInfo.Confirmations grows while the transaction is broadcasted. At RequiredConfirmations the transaction becomes confirmed. Both fields are always sent on ExecuteAsync, InfoAsync, HistoryAsync and the transaction.* webhook. The webhook is sent only on a final status; to follow the count, poll InfoAsync. On confirmed, Confirmations >= RequiredConfirmations.

Transactions.EstimateAsync quotes the network fee without signing or broadcasting and leaves no record — the same request as SignAsync minus UrlCallback. TxType.Contract is refused with CONTRACT_ESTIMATE_UNSUPPORTED.

var est = await client.Transactions.EstimateAsync(new EstimateTransactionRequest
{
    Network     = Chain.EthSepolia,
    FromAddress = "0xYourWallet...",
    Type        = TxType.Native,
    ToAddress   = "0xRecipient...",
    Value       = wei.ToString(), // base units (wei)
});

EstimateTransactionResponse: EstimatedFee — the network fee in the native coin (human-readable); Required — the total native coin the from-wallet must hold (fee + value for a native transfer, fee alone for a token one). EstimatedFeeFiat / RequiredFiat are the same in USD and are empty strings when the rate is unavailable.

On TRON the response also carries a fee breakdown: FeeExpected (what will actually be charged given the wallet's current staked / delegated / rented energy pool — not a guarantee, the pool can run out), FeeLimit (the on-chain cap written into the transaction), Energy (units needed) and EnergyFee + BandwidthFee + ActivationFee, which add up to EstimatedFee. ActivationFee is sent only on a native transfer to an address that does not exist on chain yet. Off TRON all of these are null.

Contract calls — the easy way

Most real-world transactions are smart-contract calls (token transfers, DEX swaps, Anchor program instructions, Jetton transfers). You never have to encode the data field by hand: give the library a typed description, get back a signed reservation.

EVM — Uniswap V2 swap

This snippet shows the encoder, not a complete swap. Uniswap's router moves your input token with transferFrom, so it needs an ERC-20 approve(address,uint256) on that token first, confirmed before the swap is signed — without it the swap reverts and burns the gas. And an amountOutMin of 0 accepts whatever the pool returns, which on a public mempool hands the trade to the first sandwich bot that sees it. The runnable version, with both, is in examples/.

using System.Numerics;
using CryptoChief.Processing.Services;

var amountIn     = Amount.HumanToBase("0.01", 18);
var amountOutMin = BigInteger.Zero;
var deadline     = new BigInteger(DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds());
var path         = new[] { tokenIn, tokenOut };

var signed = await client.Transactions.SignEvmCallAsync(new EvmCallRequest
{
    Network     = Chain.EthMainnet,
    FromAddress = "0xYourWallet...",
    Contract    = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D", // V2 router
    Method      = "swapExactTokensForTokens(uint256,uint256,address[],address,uint256)",
    Args        = new object?[] { amountIn, amountOutMin, path, "0xYourWallet...", deadline },
    UrlCallback = "https://your.app/webhooks/transaction",
});

The encoder supports uint/int<M>, address, bool, bytes, bytes<N>, string, and fixed / dynamic arrays of any of those. Argument values accept BigInteger, plain int/long/uint/ulong, decimal / hex strings, byte[], and IEnumerable<T> of those. Function-name aliases (uintuint256) and parameter names (uint256 amount) are normalised before hashing.

ERC-20 / TRC-20 transfers have a one-liner:

var amount = Amount.HumanToBase("12.5", 6); // USDT decimals = 6

await client.Transactions.Erc20TransferAsync(new Erc20TransferRequest
{
    Network       = Chain.EthMainnet,
    FromAddress   = "0xYourWallet...",
    TokenContract = "0xdAC17F958D2ee523a2206206994597C13D831ec7",
    Recipient     = "0x...",
    Amount        = amount,
});

TRON — same encoder, base58 addresses

TRON shares the EVM ABI. SignEvmCallAsync (or its alias SignTronCallAsync) accepts both base58 (T...) and 0x41-prefixed hex addresses transparently:

await client.Transactions.SignTronCallAsync(new EvmCallRequest
{
    Network     = Chain.TronMainnet,
    FromAddress = "TYourWallet...",
    Contract    = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", // USDT TRC-20 base58
    Method      = "transfer(address,uint256)",
    Args        = new object?[] { "TRecipient...", amount },
});

Need to convert addresses outside a call? CryptoChief.Processing.Encoders.Tron.TronAddress.ToHex / TronAddress.FromHex are public.

Solana — Anchor program call

Anchor programs use an 8-byte SHA-256 discriminator (global:<method>) followed by Borsh-encoded arguments. The SDK builds both:

using CryptoChief.Processing.Encoders.Solana;
using CryptoChief.Processing.Models;

var signed = await client.Transactions.SignAnchorCallAsync(new AnchorCallRequest
{
    Network     = Chain.SolanaMainnet,
    FromAddress = "YourWallet...",
    Program     = "YourProgramId...",
    Method      = "initialize",
    Args = new[]
    {
        Borsh.U64(1_000_000),
        Borsh.String("hello"),
        Borsh.Pubkey("Recipient..."),
    },
    Accounts = new[]
    {
        new SolanaAccount { Pubkey = "YourWallet...", IsSigner = true,  IsWritable = true },
        new SolanaAccount { Pubkey = "DataAcct...",   IsSigner = false, IsWritable = true },
        new SolanaAccount { Pubkey = "11111111111111111111111111111111", IsSigner = false, IsWritable = false },
    },
});

Borsh primitives: Borsh.U8/16/32/64/128, Borsh.I8/16/32/64, Borsh.Bool, Borsh.String, Borsh.Bytes, Borsh.FixedBytes, Borsh.Pubkey, Borsh.Option, Borsh.Vec, Borsh.Struct.

Non-Anchor program? Pass pre-built instruction bytes with SignSolanaCallAsync(new SolanaCallRequest { InstructionData = ..., Accounts = ... }).

TON — Jetton / NFT / comment in one call

TON contract bodies are program-specific cells with no Solidity-style ABI, so the SDK encodes them for you behind high-level helpers. You describe the operation in human terms.

using CryptoChief.Processing.Services;

var amount = Amount.HumanToBase("0.5", 6); // USDT Jetton has 6 decimals

var signed = await client.Transactions.JettonTransferAsync(new JettonTransferRequest
{
    Network      = Chain.TonMainnet,
    FromAddress  = "EQYourWallet...",
    JettonMaster = "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs", // USDT
    Recipient    = "EQRecipient...",
    Amount       = amount,
    Memo         = "Order #4242", // optional — wallets show this as the comment
    // AttachedTon empty → SDK picks 0.07 TON if the receiver already has a
    // Jetton wallet for this token, 0.15 TON if a new one must be deployed.
});

The sender's Jetton wallet address and gas budget are resolved automatically. If you've already pre-resolved them, pass JettonWalletAddress and AttachedTon explicitly and no network lookup happens.

NFT transfer and text comments use the same pattern:

using CryptoChief.Processing.Amounts;

await client.Transactions.NftTransferAsync(new NftTransferRequest
{
    Network     = Chain.TonMainnet,
    FromAddress = "EQYourWallet...",
    NftItem     = "EQItemAddr...",
    NewOwner    = "EQRecipient...",
    AttachedTon = Amount.NanoTon("0.05"),
});

await client.Transactions.SendTonCommentAsync(new TonCommentRequest
{
    Network     = Chain.TonMainnet,
    FromAddress = "EQYourWallet...",
    Recipient   = "EQRecipient...",
    Text        = "Thanks for the coffee!",
    AmountTon   = Amount.NanoTon("1"),
});

For non-Jetton / non-NFT contracts, build the body cell yourself and pass the bytes to the lower-level SignTonCallAsync. TonAddress.Parse is provided for offline address validation / round-tripping the EQ.../UQ... forms.

Accepting payments (invoices / pay-ins)

A pay-in is an invoice the customer pays in crypto. Use PayInMode.Fiat to quote the customer in fiat (e.g. $10) and let them pick the coin/network at payment time, or PayInMode.Crypto to fix the exact coin/amount up front.

using CryptoChief.Processing.Chains;
using CryptoChief.Processing.Models;
using CryptoChief.Processing.Polling;

// FIAT — customer is shown the asset menu
var invoice = await client.PayIns.CreateAsync(new CreatePayInRequest
{
    OrderId     = $"order-{Guid.NewGuid():N}",
    UserId      = "user-7",
    Mode        = PayInMode.Fiat,
    AmountFiat  = "10.00",
    Currency    = "USD",
    LifetimeSec = 3600,
    UrlCallback = "https://your.app/webhooks/invoice",
    UrlSuccess  = "https://your.app/checkout/success",
    UrlError    = "https://your.app/checkout/error",
});

// Either send the customer to invoice.PaymentLink (hosted page)
// or implement your own checkout — list invoice.Coins and call SelectAsset:
if (invoice.Status == PayInStatus.WaitingAssetSelect)
{
    invoice = await client.PayIns.SelectAssetAsync(new SelectAssetRequest
    {
        Uuid    = invoice.Uuid,
        Coin    = "USDT",
        Network = Chain.TronMainnet,
    });
    // invoice.ToAddress and invoice.AmountCrypto are now populated.
}

// Block until paid / cancelled / expired.
var final = await client.WaitForPayInAsync(invoice.Uuid,
    new PollOptions { Interval = TimeSpan.FromSeconds(10), Timeout = TimeSpan.FromMinutes(30) });
Console.WriteLine($"final: {final.Status} ({final.AmountCrypto} {final.PaymentCoin})");

CRYPTO mode fixes the asset up front — no asset-selection step:

var invoice = await client.PayIns.CreateAsync(new CreatePayInRequest
{
    OrderId      = "order-1",
    UserId       = "user-7",
    Mode         = PayInMode.Crypto,
    AmountCrypto = "10",
    Asset        = new Asset { Coin = "USDT", Network = Chain.TronMainnet },
    UrlCallback  = "https://your.app/webhooks/invoice",
});
// invoice.ToAddress is the deposit address — show it to the customer.

Inbound webhooks land on UrlCallback carrying a PayInWebhookEvent — verify with WebhookVerifier (see below).

Wallets and RSA-encrypted private keys

When the API generates a wallet it returns the private key encrypted with the RSA public key you uploaded in the dashboard (Project Settings → RSA Key). The SDK can decrypt it locally:

# one-time setup: generate a keypair and upload rsa_public.pem to the dashboard
openssl genrsa -out rsa_private.pem 2048
openssl rsa -in rsa_private.pem -pubout -out rsa_public.pem
var client = new CryptoChiefClient(new CryptoChiefClientOptions
{
    MerchantId = "...",
    ApiKey     = "...",
}.LoadRsaPrivateKeyFromFile("./rsa_private.pem"));
// Or LoadRsaPrivateKeyFromPem("-----BEGIN...");

var w = await client.Wallets.GenerateAsync(new GenerateWalletRequest
{
    WalletType  = WalletType.Master,
    ChainFamily = ChainFamily.Evm,
    Label       = "Treasury EU",   // optional, up to 255 chars, any wallet type
});

// w.PrivateKeyEncrypted is base64 RSA-OAEP / SHA-256 ciphertext.
var privHex = client.Wallets.DecryptPrivateKey(w.PrivateKeyEncrypted!);
// privHex is the chain-native hex form — keep it safe.

LoadRsaPrivateKeyFromPem/File accepts both PKCS#1 (openssl genrsa default) and PKCS#8 (-----BEGIN PRIVATE KEY-----).

If you skip the option, WalletsService.DecryptPrivateKey throws a CryptoChiefException and the rest of the SDK continues to work — decryption is purely opt-in.

Re-pointing a wallet at another master

A transit or static wallet can be moved to another master wallet of the same project after it exists:

var w = await client.Wallets.RebindMasterAsync(depositAddress, newMasterAddress);
Console.WriteLine(w.MasterWalletAddress);  // the master it now sweeps to

This moves no money. It changes where the next sweep settles — including sweeps already queued, which will land on the new master — while anything already swept stays on the previous one.

It is idempotent: a wallet already bound to that master answers 200 and changes nothing. Master wallets cannot be re-pointed, and the new master must be of the same chain family and not frozen.

Changing a static wallet's deposit webhook

CallbackUrl can be set at generation time, and rewritten or cleared afterwards:

await client.Wallets.SetCallbackUrlAsync(staticAddress, "https://your.app/hooks/deposit");

// Clearing it is an empty string, not a null — the SDK sends "" on the wire.
var w = await client.Wallets.SetCallbackUrlAsync(staticAddress, "");
Console.WriteLine(w.CallbackUrl is null);  // True

Static wallets only — master and transit wallets are refused with 400. A deposit already announced is not announced again to the new URL.

Naming a wallet

Label can be set at generation time, and renamed or cleared afterwards. It applies to every wallet type — master, transit and static alike — unlike the deposit webhook, which is static-only:

await client.Wallets.SetLabelAsync(masterAddress, "Treasury EU");

// Clearing the name is an empty string, not a null — the SDK sends "" on the wire.
var w = await client.Wallets.SetLabelAsync(masterAddress, "");
Console.WriteLine(w.Label is null);  // True

Up to 255 characters; longer is refused with LABEL_TOO_LONG.

Label comes back on every response that describes a wallet — generation, info, the list, and what rebind-master, callback-url and label themselves return — so a bulk create no longer hands back items you can only tell apart by address:

var wallets = await client.Wallets.ListAsync();
foreach (var wallet in wallets.Items)
    Console.WriteLine($"{wallet.Label ?? "(unnamed)"}{wallet.Address}");

All three methods return the wallet-info shape, where MasterWalletAddress, CallbackUrl and Label are always present and null when the wallet has no such value — never an empty string, never an absent key. A transit wallet always reads CallbackUrl is null.

Webhooks

Webhooks are signed with HMAC-SHA256 v1 using the API key:

Header Value
X-Webhook-Delivery delivery id, 1–128 characters [A-Za-z0-9_-]; the same on every attempt and resend
X-CC-Timestamp Unix time of the attempt, seconds, decimal without leading zeros
X-CC-Signature v1= + 64 hex characters
string_to_sign = "CC-HMAC-SHA256-WEBHOOK-V1" \n X-CC-Timestamp \n X-Webhook-Delivery \n hex(sha256(body))
signature      = "v1=" + hex(hmac_sha256(key = apiKey, message = string_to_sign))

Verify the raw request body before parsing it:

using System.Text.Json;
using CryptoChief.Processing.Errors;
using CryptoChief.Processing.Webhooks;
using CryptoChief.Processing.Webhooks.Events;

app.MapPost("/webhooks/payout", async (HttpRequest req) =>
{
    using var ms = new MemoryStream();
    await req.Body.CopyToAsync(ms);
    try
    {
        var evt = WebhookVerifier.VerifyAndDecode<PayoutWebhookEvent>(apiKey, ms.ToArray(), req.Headers);
        // process evt; deduplicate by req.Headers[WebhookVerifier.DeliveryHeader]
        return Results.Ok();
    }
    catch (WebhookVerificationException)
    {
        return Results.Unauthorized();
    }
    catch (Exception ex) when (ex is JsonException or CryptoChiefException)
    {
        return Results.BadRequest();
    }
});

VerifyAndDecode<T> throws past a successful verification when the body is not JSON of T (JsonException) or is JSON null (CryptoChiefException). Neither derives from WebhookVerificationException, so catch them too: uncaught they answer 5xx, which the platform retries.

Verify, TryVerify and VerifyAndDecode<T> take the body as ReadOnlySpan<byte> (a string: Encoding.UTF8.GetBytes(body)) and the headers as any of:

  • IEnumerable<KeyValuePair<string, StringValues>> — ASP.NET Core req.Headers;
  • HttpHeaders;
  • IEnumerable<KeyValuePair<string, IEnumerable<string>>> or IEnumerable<KeyValuePair<string, string>>;
  • Func<string, string?> — a value by name, null when absent. One string cannot show every copy of a repeated header: an empty copy is not seen. In ASP.NET Core pass req.Headers, not name => req.Headers[name].

Header names are case-insensitive. Checks, in order:

Check Exception
each header present once; values trimmed of spaces and tabs only; no CR/LF; timestamp decimal without leading zeros, delivery id and v1= + 64 hex in format WebhookHeadersException
|now − timestamp| ≤ tolerance (default 300 s) WebhookTimestampException
HMAC, constant-time, hex in any case WebhookSignatureException

All three derive from WebhookVerificationException (a CryptoChiefException); answer 401. An empty API key throws ArgumentException. TryVerify returns false instead of throwing.

var ok = WebhookVerifier.TryVerify(apiKey, body, req.Headers, new WebhookVerifyOptions
{
    Tolerance = TimeSpan.FromSeconds(60),
    Now       = () => DateTimeOffset.UtcNow,
});

RequestSigner.SignWebhookV1(apiKey, timestamp, deliveryId, body) returns the X-CC-Signature value; RequestSigner.WebhookV1StringToSign returns the string to sign.

WebhookVerifier.SenderIps lists the addresses the processing platform delivers webhooks from — whitelist them at your edge for defence in depth. White-label installations deliver from their own address.

Typed event payloads: PayoutWebhookEvent, TransactionWebhookEvent, PayInWebhookEvent, StaticDepositWebhookEvent, SweepWebhookEvent.

Error handling

Errors from the API are thrown as CryptoChiefApiException with a stable Code field:

using CryptoChief.Processing.Errors;

try
{
    await client.Payouts.ExecuteAsync(req);
}
catch (CryptoChiefApiException ex)
{
    switch (ex.Code)
    {
        case ErrorCodes.InsufficientFunds:       /* need top-up */ break;
        case ErrorCodes.AssetNotEnabled:         /* unsupported coin/network */ break;
        case ErrorCodes.DebtLimitExceeded:       /* postpaid debt cap hit */ break;
        case ErrorCodes.FromWalletNotOwned:      /* wallet doesn't belong to this project */ break;
        case ErrorCodes.AlreadyExecuted:         /* duplicate execute */ break;
        case ErrorCodes.BatchDuplicateOrderId:   /* batch validation */ break;
        default:                                 /* anything else */ break;
    }
}

ex.Code is a machine code, read from either error body:

Body Code Message
{"ok":false,"error":"CODE","msg":"..."} error msg
{"ok":false,"error":"SERVICE_ERROR","msg":"CODE"} msg msg
{"data":null,"error":{"status":...,"name":"...","message":"...","details":{"code":"CODE"}}} error.details.code, else error.name error.message

With no code in the body, Code is HTTP_<status>. ex.Message includes the message; ex.RawBody keeps the body as received.

ex.IsRetryable tells you whether the operation is plausibly transient (5xx, network).

Amounts

Never use double or decimal for crypto amounts. The full base-unit range exceeds either type's precision. Use Amount.HumanToBase / Amount.BaseToHuman (backed by System.Numerics.BigInteger):

var wei = Amount.HumanToBase("1.5", 18);
// wei = BigInteger 1500000000000000000

var human = Amount.BaseToHuman(wei, 18);
// human = "1.5"

The API accepts both human strings (the amount field on most endpoints) and base-unit integer strings (the value field on /transaction/signature). HumanToBase is precise to the last digit; sub-base-unit precision is truncated to match every blockchain client's behaviour.

For TON specifically, Amount.NanoTon("0.05") returns the nanoTON decimal string that AttachedTon / ForwardTonAmount expect.

Configuration

var options = new CryptoChiefClientOptions
{
    MerchantId        = "...",
    ApiKey            = "...",
    BaseUrl           = "https://api-processing.crypto-chief.com", // default
    Timeout           = TimeSpan.FromSeconds(60),
    MaxRetries        = 3,
    InitialRetryDelay = TimeSpan.FromMilliseconds(200),
    MaxRetryDelay     = TimeSpan.FromSeconds(5),
    UserAgent         = "my-service/1.0",
};
options.LoadRsaPrivateKeyFromFile("./rsa_private.pem"); // optional

var client = new CryptoChiefClient(options);

Test mode is a per-project toggle in the dashboard, not a separate base URL — point a test-mode project's credentials at the same client.

Request signing

Each request is signed with HMAC-SHA256 v1:

Header Value
Merchant MerchantId
X-CC-Timestamp Unix time, seconds
X-CC-Nonce 32 lowercase hex characters, new on every attempt
X-CC-Signature v1= + 64 lowercase hex characters
string_to_sign = "CC-HMAC-SHA256-REQ-V1" \n timestamp \n nonce \n METHOD \n path \n query
                 \n merchant \n idempotency_key \n hex(sha256(body))
signature      = hex(hmac_sha256(key = apiKey, message = string_to_sign))
  • path — route from /v1/, without the base URL and query (/v1/payout/execute).
  • query — without ?; empty if none. idempotency_key — empty if not sent.
  • body — the exact bytes sent; empty body hashes to e3b0c442…b855.

The body is the request serialized once with System.Text.Json (snake_case names, null members omitted); every attempt sends and signs the same bytes.

Timestamp, nonce and signature are computed on every retry. On SIGNATURE_TIMESTAMP_OUT_OF_RANGE the client sets its clock offset from server_time (top level, or error.details.server_time) and repeats the request once.

var signature = RequestSigner.SignHmacV1(apiKey, new HmacV1Input
{
    Timestamp = "1789430400",
    Nonce     = RequestSigner.NewNonce(),
    Method    = "POST",
    Path      = "/v1/wallets/info",
    Merchant  = merchantId,
    Body      = bodyBytes,
});
// X-CC-Signature: {signature} — the value already carries the v1= prefix

Idempotency

Payouts.ExecuteAsync and Payouts.BatchExecuteAsync are idempotent on OrderId: re-submitting the same order_id returns the same uuid rather than creating a second payout. The library's automatic retry on 5xx relies on this — your callers don't need any extra ceremony.

Runnable examples

The examples/ directory has runnable programs you can copy from:

  • Quickstart — list enabled assets, estimate + execute + poll a payout.
  • InvoiceCreate — accept an incoming crypto payment (FIAT or CRYPTO mode pay-in), select asset, wait for payment.
  • UniswapSwap — V2 swap via one-line ABI encoding.
  • JettonTransfer — TON Jetton transfer with auto-resolved wallet + memo.
  • WebhookServer — ASP.NET Core minimal API that verifies inbound payout / transaction / invoice / sweep webhooks.
cd examples/Quickstart
MERCHANT_ID=... API_KEY=... TO_ADDRESS=0x... dotnet run

FAQ — common crypto-processing tasks in C#

How do I accept a crypto payment in .NET? Create a pay-in (invoice) with client.PayIns.CreateAsync(...); the customer gets a deposit address and you receive a signed webhook when it's paid. See PayInsService.

How do I send a crypto payout (withdrawal) in .NET? client.Payouts.ExecuteAsync(...) with Coin / Network / Amount / ToAddress. Pass OrderId as an idempotency key and use client.WaitForPayoutAsync to block until confirmed. Works for native coins and ERC-20 / TRC-20 stablecoins (USDT, USDC).

How do I send a mass / batch crypto payout? client.Payouts.BatchExecuteAsync(...) — up to 50 recipients in one signed request, processed sequentially so the double-spend invariant holds.

How do I call a smart contract (ERC-20, Uniswap) without encoding calldata? client.Transactions.SignEvmCallAsync(...), or Erc20TransferAsync for a token-transfer one-liner. Give it a Solidity signature plus args and the SDK ABI-encodes the data field for you.

How do I transfer USDT on TON (a Jetton) in .NET? client.Transactions.JettonTransferAsync(...) — pass the Jetton master, recipient, and amount; the sender's Jetton wallet address and gas budget are resolved automatically.

How do I move a deposit wallet to a different master wallet? client.Wallets.RebindMasterAsync(address, newMaster) — it re-points where the next sweep settles (queued sweeps included) without moving anything already swept. Transit and static wallets only.

How do I give a wallet a human-readable name? client.Wallets.SetLabelAsync(address, "Treasury EU"), or Label on GenerateWalletRequest at creation time. Every wallet type takes one, and Wallet.Label is returned by every call that describes a wallet — passing "" clears the name.

A payer says they sent funds and I only have the address — which order was it? client.Wallets.HistoryAsync(new WalletHistoryQuery { Address = address }) returns the pay-ins that used that deposit address, as the same PayIn records and Meta block client.PayIns.HistoryAsync gives you. A deposit wallet serves several orders over its lifetime, so this is a page of them. The address is matched case-insensitively, and one your project does not own yields an empty page rather than an error.

Which assets could we turn on, and which are enabled right now? client.Blockchain.ContractsListAsync() is the platform-wide catalogue — every coin and token on every network; ContractsAvailableAsync() is what your project can actually be paid in. Both hand back the same item, so read ChainFamily and IsTest to tell a testnet asset from a live one, and expect Contract to be "" (not null) on a native coin.

Which fiat currencies and crypto tickers can I quote against? client.Currencies.FiatsAsync() lists the fiat codes a pay-in or a rate quote accepts; client.Currencies.CryptosAsync() lists the crypto tickers the platform has a USDT rate for, with ByExchange naming which exchange each came from. Rate availability only — a ticker there is not a promise of deposits, sweeps or payouts in it.

How do I verify a Crypto Chief webhook signature? WebhookVerifier.Verify(apiKey, body, req.Headers) over the raw body, or WebhookVerifier.VerifyAndDecode<T>(apiKey, body, req.Headers) for one-line typed dispatch.

Which blockchains does the crypto processing API support? Ethereum, BNB Smart Chain, Polygon, Tron, TON, Solana, Bitcoin, Litecoin, Dogecoin, XRP, Avalanche, Arbitrum, Optimism and more — 25 chains in total. The constants live in CryptoChief.Processing.Chains.Chain. For the live list — the chains the platform's scanner is connected to right now — call client.Blockchain.BlockchainsListAsync().

How do I avoid floating-point rounding bugs with crypto amounts? Never use double. Convert with Amount.HumanToBase / Amount.BaseToHuman, which are backed by System.Numerics.BigInteger.

Auto-sweep settings

A deposit wallet is swept to your master wallet on a policy: as soon as funds arrive, once the balance reaches an amount, or never on its own (a force sweep still works).

var s = await client.Sweeps.UpdateSettingsAsync(depositAddress,
    typeWork: SweepFieldWrite.Set(SweepPolicyMode.Threshold),
    thresholdAmountUsd: SweepFieldWrite.Set("250"));

Console.WriteLine(s.Effective.TypeWork);  // what will actually happen
Console.WriteLine(s.Effective.Source);    // which layer decided it

SettingsAsync comes back in three layers — Effective (what will happen), Override (what this wallet decides for itself) and ProjectDefault (what it falls back to) — because only the three together say whether a value is yours or inherited.

Inheritance is per field: writing the mode leaves the fee mode inherited. A null argument leaves a field alone; SweepFieldWrite.Inherit stops overriding it — the field is named in the fields mask with no value, which is the only way to clear one and keep the others. The mask covers type_work, threshold_amount_usd, fee_mode and gas_source.

Who funds the gas

A deposit wallet holding enough of the chain's native coin pays for its own transfer whatever the mode is — fee_mode only decides who covers a shortfall.

Constant Who covers the shortfall
SweepFeeMode.Client Your own master wallet.
SweepFeeMode.Service The platform — and the cost is billed to your API credits.
SweepFeeMode.Mix The default. client first, falling back to service when the master wallet cannot cover it.

Who buys the energy on TRON

gas_source says what is bought for a TRON transfer where fee_mode says who covers the network fees — the two are independent, and the energy is billed to your API credits under any fee mode.

var s = await client.Sweeps.SettingsAsync(new SweepSettingsQuery { Address = tronAddress });

Console.WriteLine(s.Effective.GasSource);  // always concrete — what will actually happen
Console.WriteLine(s.Override?.GasSource);  // null = this layer does not decide

Not setting it is not the same as setting native. A wallet that has never chosen a gas_source gets the platform default, SweepGasSource.Rented — so energy is supplied, and billed to your credits, without anybody having switched it on. A null in Override means only that the layer does not decide; the value is inherited, not off. To have the wallet burn its own TRX, write it explicitly:

await client.Sweeps.UpdateSettingsAsync(tronAddress,
    gasSource: SweepFieldWrite.Set(SweepGasSource.Native));

Carried and ignored on every chain other than TRON.

Finding one sweep again

Both history calls take Status (one SweepStatus) and Search (substring). Leaving Status out includes every status — the SweepStatus.Skipped ones among them, which is where a sweep that never happened is hiding.

var page = await client.Sweeps.HistoryAsync(new SweepHistoryQuery
{
    Status = SweepStatus.Failed,
    Search = "0x77EDde",       // wallet address, either tx hash, or the task id
});

On WalletHistoryAsync the wallet is already fixed by Address, so Search matches the transaction hashes and the task id.

A sweep is broadcast first and completed after: while it is SweepStatus.Broadcasted, SweepConfirmations grows; it becomes SweepStatus.Completed when the count reaches RequiredConfirmations. The funds have arrived on Completed with SweepConfirmations above zero. A count above zero without Completed is not settlement. Older records can be Completed with 0: not settled. The sweep.confirmed webhook is sent once, on completion, with both counts.

CompletedAt is not a settlement signal. It is set when the sweep transaction is sent (for waiting_gas, failed and skipped, when that status was recorded), so a broadcasted sweep already has it. Check Status == Completed with SweepConfirmations > 0, or take ConfirmedAt off the sweep.confirmed webhook.

Manual withdrawals

client.Withdrawals reads withdrawals made from the project's wallets; they are not created through this API and send no webhooks. Statuses (WithdrawalStatus):

Status Meaning
queue Waiting to be processed.
refueling The source wallet is being topped up with native coin for gas.
refuel_confirmed Gas is in place; the transfer is about to be sent.
sending The transfer is being signed and sent.
broadcasting Handed off for broadcast, hash not known yet (EVM).
in_mempool In the mempool, not in a block yet (UTXO networks).
confirm_check Sent; waiting for RequiredConfirmations.
completed Terminal: the transaction reached RequiredConfirmations.
failed Terminal: ErrorReason says why.

Confirmations is absent before the first block and 0 while no confirmations are counted yet, including after the transaction left a block (status stays confirm_check). On completed, Confirmations >= RequiredConfirmations. RequiredConfirmations is always sent.

var wd = await client.Withdrawals.InfoAsync("b0d1f7f9-1eaa-4c2f-8f9b-2b0d1b0b9f11");

if (wd.Succeeded)
    Console.WriteLine($"completed at {wd.CompletedAt}: tx={wd.TxHash}, fee ${wd.ActualFeeFiat}");
else if (wd.Status == WithdrawalStatus.ConfirmCheck)
    Console.WriteLine($"on its way: {wd.Confirmations?.ToString() ?? "not in a block yet"}/{wd.RequiredConfirmations}");
else if (wd.IsTerminal)
    Console.WriteLine($"{wd.Status}: {wd.ErrorReason}");

HistoryAsync returns the same shape per item and pages by Page, PageSize, DateFrom and DateTo; it has no status filter. Error, ConfirmedAt, UpdatedAt, Contract and AmountFiat are not sent and stay null; use ErrorReason and CompletedAt.

Renting TRON energy

A TRON transfer is cheaper when its energy is rented than when TRX is burnt. client.Energy prices a rental, places it, and reads it back — charges go to the same project credits balance as everything else. receive_address is the sender of the transfer the energy pays for.

// Free quote: the rent price next to what burning TRX would cost (Burn*) and the Saving*.
var quote = await client.Energy.QuoteAsync(new EnergyQuoteRequest
{
    ReceiveAddress = "TSender...", // who sends the transfer
    Energy         = 65_000,       // optional — platform default when null
});

// Rent is synchronous and requires an Idempotency-Key (400 without it): by the time it
// answers, the energy is either delegated or the refusal reason is known.
var order = await client.WithIdempotencyKey("energy-order-42").Energy.RentAsync(
    new EnergyRentRequest { ReceiveAddress = "TSender...", QuoteRef = quote.Ref });

if (order.Status == EnergyOrderStatus.Delivered)
    Console.WriteLine($"{order.DeliveredEnergy} energy delegated for {order.Credits} credits");
else
    Console.WriteLine($"{order.Status}: {order.Error} ({order.ErrorCode})"); // Credits is null when nothing was charged

// Same order again later, by its idempotency key:
var again = await client.Energy.OrderAsync("energy-order-42");

A refused order (HTTP 502, or 402 when the credits balance is short) and an unresolved one (HTTP 409, NeedsAttention set) come back as the order, not as an exception — the else branch above is where they land. A retry with the same key returns the same order rather than renting twice; re-attempt a refusal with a NEW key. A NeedsAttention order must not be retried — the energy may already be delegated; follow it with OrderAsync. Failures with no order to report (an error envelope, a gateway error page) throw a CryptoChiefApiException as usual.

Buying native coin with credits

The platform sells native coin (TRX, ETH, BNB, SOL, TON, ...) from its own liquidity, billed to your project credits balance. The price covers the coins at the market rate plus the fee of the platform's own transfer — the transfer fee is included, nothing else is charged on top. total_usd is the full sale price and credits is the exact amount charged to the balance. receive_address is any address you want the coin on.

// Free quote: the coin cost, the platform's transfer fee and the credits total.
var quote = await client.Native.QuoteAsync(new NativeQuoteRequest
{
    Network        = "TRON",
    ReceiveAddress = "TRecipient...",
    Amount         = "0.05", // human units
});

// Buy is synchronous and requires an Idempotency-Key (400 without it): by the time it
// answers, the coin is either sent or the refusal reason is known.
var order = await client.WithIdempotencyKey("native-order-42").Native.BuyAsync(
    new NativeBuyRequest { QuoteRef = quote.Ref }); // or Network + ReceiveAddress + Amount

if (order.Status == NativeOrderStatus.Delivered)
    Console.WriteLine($"{order.Amount} sent as {order.TxHash} for {order.Credits} credits");
else
    Console.WriteLine($"{order.Status}: {order.Error} ({order.ErrorCode})"); // Credits is null when nothing was charged

// Same order again later, by its idempotency key:
var again = await client.Native.OrderAsync("native-order-42");

A refused order (HTTP 502, or 402 when the credits balance is short) and an unresolved one (HTTP 409, NeedsAttention set) come back as the order, not as an exception — the else branch above is where they land. A retry with the same key returns the same order rather than buying twice; a NEW key re-attempts the purchase. A NeedsAttention order must not be retried — the coins may already be sent; follow it with OrderAsync. Failures with no order to report throw a CryptoChiefApiException: a 409 QUOTE_EXPIRED or QUOTE_ALREADY_USED means quote again (a quote lives about 90 seconds and is single-use), a 402 INSUFFICIENT_CREDITS envelope means the credits balance needs a top-up.

Documentation

Full guides, tutorials, and recipes → docs-sdk.crypto-chief.com/processing/dotnet

Reference material:

Contributing

PRs welcome. Please run dotnet test and dotnet build -c Release before opening; new endpoints should come with a test that exercises the wire shape through the in-memory HttpMessageHandler fixture in tests/CryptoChief.Processing.Tests/TransportTests.cs.

License

MIT — see LICENSE.

About

Official .NET / C# SDK for the Crypto Chief crypto processing API — accept crypto payments, send single & mass payouts, sign EVM / TRON / Solana / TON contract calls (ERC-20, USDT, USDC, Jetton), verify webhooks. 25+ blockchains.

Topics

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages