1. Guides

Migrate from Carbon 1.x to 2.0

Upgrade from Carbon 1.x to 2.0: a checklist with before/after code for the toolchain, Solana crates, transaction configs, account deletions and the CLI.

On this page

Carbon 2.0 is the Agave 4 compatibility release: it moves to the Agave 4.2 Solana crates, supports Transaction V1 end to end, and delivers account closures the same way from every datasource. The pipeline, processor and decoder APIs are unchanged, so most indexers only need dependency bumps and a few constructor changes. This page lists every breaking change from 1.x, from the 2.0.0 changelog and the crates themselves.

Checklist#

Toolchain#

Carbon 2.0 crates declare rust-version = "1.96.1"; 1.x built with Rust 1.88.

Terminal
rustup update stable
rustc --version

If your project pins a toolchain, update it:

rust-toolchain.toml
 [toolchain]
-channel = "1.88.0"
+channel = "1.96.1"

The CLI and its renderer need Node.js 20.18.0 or newer (the 1.x-era CLI, 0.12.0, accepted Node 18).

Dependencies#

Bump every Carbon crate you declare to 2.0.0, and every Solana crate you declare directly to the version Carbon 2.0 uses. Carbon 1.x used the Solana 3.x crates (solana-pubkey 3, solana-client 3, yellowstone-grpc-proto 10).

Cargo.toml
 [dependencies]
-carbon-core = "1.0.0"
-carbon-yellowstone-grpc-datasource = "1.0.0"
-carbon-pump-swap-decoder = "1.0.0"
-solana-pubkey = "3.0.0"
-solana-client = "3.0.0"
-yellowstone-grpc-proto = "10.0.0"
+carbon-core = "2.0.0"
+carbon-yellowstone-grpc-datasource = "2.0.0"
+carbon-pump-swap-decoder = "2.0.0"
+solana-pubkey = "4.2.1"
+solana-client = "=4.2.2"
+yellowstone-grpc-proto = { version = "=12.7.0", default-features = false }

The versions Carbon 2.0 pins, for the crates you're most likely to declare:

CrateVersion
solana-pubkey4.2.1
solana-account4.3.2
solana-signature3.4.1
solana-instruction3.4.1
solana-message4.4.1
solana-hash4.5.0
solana-transaction4.1.6
solana-commitment-config3.1.1
solana-client, solana-transaction-status, solana-account-decoder=4.2.2
yellowstone-grpc-proto=12.7.0
yellowstone-grpc-client=13.5.0
helius2.0.0
sqlx / sqlx_migrator (with postgres)0.8.6 / 0.17.0
juniper / axum (with graphql)0.16 / 0.8

Then update and list the versions your own crate depends on directly:

Terminal
cargo update
cargo tree -e normal --depth 1 | grep -E "solana|yellowstone|carbon"
Output
my-indexer v0.1.0 (/home/you/my-indexer)
├── carbon-core v2.0.0
├── carbon-meteora-dlmm-decoder v2.0.0
├── carbon-prometheus-metrics v2.0.0
├── carbon-yellowstone-grpc-datasource v2.0.0
├── solana-hash v4.5.0
├── solana-message v4.4.1
├── solana-pubkey v4.2.1
├── solana-signature v3.4.1
├── solana-transaction v4.1.6
├── solana-transaction-status v4.2.2
├── yellowstone-grpc-client v13.5.0
└── yellowstone-grpc-proto v12.7.0

Every Carbon crate must be 2.0.0, and every solana-* crate you list must match the table. Don't use cargo tree -d as the check: Agave 4.2's own crates already pull a second copy of some type crates (solana-account 3.4.0 and solana-pubkey 3.0.0, through solana-config-interface), which is expected and harmless.

A Solana type crate on the wrong major version fails where a Carbon type meets yours:

Output
error[E0308]: mismatched types
   |  expected `solana_account::Account`, found a different `solana_account::Account`
note: there are multiple different versions of crate `solana_account` in the dependency graph

Don't mix Carbon 1.x and 2.0 crates in one graph either.

Datasources not in 2.0#

The 2.0 release publishes twelve datasource crates. carbon-jito-shredstream-grpc-datasource (no Transaction V1 support yet) and carbon-jetstreamer-datasource (not yet on the Solana v4 stack) stay on 1.x and can't be used in a Carbon 2.0 dependency graph. Keep indexers that depend on them on 1.x for now, or move them to another datasource.

Regenerate decoders with the 2.0 CLI#

Decoders generated by earlier CLI versions depend on Carbon 1.x crates. Regenerate them from the same IDL with the 2.0 CLI. The CLI's version on npm is 2.0.0 (the changelog calls this release of the CLI, renderer and version-registry packages 0.13; npm has no 0.13).

Terminal
npx -y @sevenlabs-hq/carbon-cli@2.0.0 parse --idl ./idl.json --out-dir ./decoders/my-program --name my-program
Terminal
pnpm dlx @sevenlabs-hq/carbon-cli@2.0.0 parse --idl ./idl.json --out-dir ./decoders/my-program --name my-program
Terminal
bunx @sevenlabs-hq/carbon-cli@2.0.0 parse --idl ./idl.json --out-dir ./decoders/my-program --name my-program
Terminal
yarn dlx @sevenlabs-hq/carbon-cli@2.0.0 parse --idl ./idl.json --out-dir ./decoders/my-program --name my-program

parse deletes --out-dir first, so commit or move any hand edits before you run it. Other CLI changes:

  • scaffold now rejects --with-graphql true with --with-postgres false: the generated GraphQL layer reads the Postgres tables.
  • Codama IDLs: native Codama events are rendered, and event-CPI discriminators are exported (--event-cpi-discriminator overrides the default Anchor tag).
  • With --with-base58 true, pubkeys in instruction metadata serialize as base58 too.

See Generate a decoder and the CLI reference.

Transaction source configuration#

Carbon 2.0 reconstructs transactions from binary encodings only and needs max_supported_transaction_version: Some(1) to receive V1 transactions. Configs you pass yourself must say so. RpcTransactionCrawler and the Helius GTFA datasource set both internally.

RpcBlockSubscribe#

Rust
// Carbon 1.x
let filters = Filters::new(
    RpcBlockSubscribeFilter::MentionsAccountOrProgram(program_id.to_string()),
    Some(RpcBlockSubscribeConfig {
        encoding: Some(UiTransactionEncoding::Base64),
        transaction_details: Some(TransactionDetails::Full),
        max_supported_transaction_version: Some(0),
        ..RpcBlockSubscribeConfig::default()
    }),
);
Rust
// Carbon 2.0
let filters = Filters::new(
    RpcBlockSubscribeFilter::MentionsAccountOrProgram(program_id.to_string()),
    Some(RpcBlockSubscribeConfig {
        encoding: Some(UiTransactionEncoding::Base64),
        transaction_details: Some(TransactionDetails::Full),
        max_supported_transaction_version: Some(1),
        ..RpcBlockSubscribeConfig::default()
    }),
);
let datasource = RpcBlockSubscribe::new(rpc_ws_url, filters);

If you requested Json or JsonParsed, switch to Base64: Carbon can't rebuild the original transaction from them.

RpcBlockCrawler#

Rust
// Carbon 2.0
let datasource = RpcBlockCrawler::new(
    rpc_url,
    467_000_000,       // start_slot
    Some(467_000_100), // end_slot
    None,              // block_interval
    RpcBlockConfig {
        encoding: Some(UiTransactionEncoding::Base64),
        transaction_details: Some(TransactionDetails::Full),
        max_supported_transaction_version: Some(1),
        ..RpcBlockConfig::default()
    },
    None, // max_concurrent_requests
    None, // channel_buffer_size
);

The 1.x code is the same with max_supported_transaction_version: Some(0).

HeliusWebsocket transaction subscriptions#

The Helius SDK's TransactionSubscribeOptions::default() asks for jsonParsed and version 0. Override both:

Rust
// Carbon 2.0
use helius::types::{
    Cluster, RpcTransactionsConfig, TransactionSubscribeFilter, TransactionSubscribeOptions,
    UiEnhancedTransactionEncoding,
};

let filters = Filters::new(
    vec![],
    Some(RpcTransactionsConfig {
        filter: TransactionSubscribeFilter {
            account_include: Some(vec![program_id.to_string()]),
            ..TransactionSubscribeFilter::default()
        },
        options: TransactionSubscribeOptions {
            encoding: Some(UiEnhancedTransactionEncoding::Base64),
            max_supported_transaction_version: Some(1),
            ..TransactionSubscribeOptions::default()
        },
    }),
)?;
let datasource = HeliusWebsocket::new(api_key, filters, Cluster::MainnetBeta);

Filters here is carbon_helius_atlas_ws_datasource::Filters; add helius = "2.0.0" to use the SDK types.

Account deletions#

The account_deletions_tracked argument is gone#

In 1.x these four constructors took an Arc<RwLock<HashSet<Pubkey>>> of accounts to report closures for. Remove the argument.

YellowstoneGrpcGeyserClient::new

Rust
// Carbon 1.x
let datasource = YellowstoneGrpcGeyserClient::new(
    endpoint,
    x_token,
    Some(CommitmentLevel::Confirmed),
    account_filters,
    transaction_filters,
    BlockFilters::default(),
    Arc::new(RwLock::new(HashSet::new())), // account_deletions_tracked
    YellowstoneGrpcClientConfig::default(),
    None,
    None,
);
Rust
// Carbon 2.0
let datasource = YellowstoneGrpcGeyserClient::new(
    endpoint,
    x_token,
    Some(CommitmentLevel::Confirmed),
    account_filters,
    transaction_filters,
    BlockFilters::default(),
    YellowstoneGrpcClientConfig::default(),
    None, // disconnect_notifier
    None, // stream_timeout
);

LaserStreamGeyserClient::new

Rust
// Carbon 1.x
let datasource = LaserStreamGeyserClient::new(
    endpoint,
    api_key,
    Some(CommitmentLevel::Confirmed),
    account_filters,
    transaction_filters,
    BlockFilters::default(),
    Arc::new(RwLock::new(HashSet::new())), // account_deletions_tracked
    config,
);
Rust
// Carbon 2.0
let datasource = LaserStreamGeyserClient::new(
    endpoint,
    api_key,
    Some(CommitmentLevel::Confirmed),
    account_filters,
    transaction_filters,
    BlockFilters::default(),
    config,
);

HeliusWebsocket::new

Rust
// Carbon 1.x
let datasource = HeliusWebsocket::new(api_key, filters, Arc::new(RwLock::new(HashSet::new())), Cluster::MainnetBeta);
Rust
// Carbon 2.0
let datasource = HeliusWebsocket::new(api_key, filters, Cluster::MainnetBeta);

StreamMessageClient::new

Rust
// Carbon 1.x
let datasource = StreamMessageClient::new(Arc::new(RwLock::new(HashSet::new())), rx);
Rust
// Carbon 2.0
let datasource = StreamMessageClient::new(rx);

The matching public account_deletions_tracked fields were removed too.

Zero-lamport accounts are now always deletions#

Carbon 1.xCarbon 2.0
Zero-lamport update for a tracked pubkey, empty data, owned by the System ProgramUpdate::AccountDeletionUpdate::AccountDeletion
Same, pubkey not trackedDroppedUpdate::AccountDeletion
Zero lamports, other owner or non-empty dataUpdate::AccountUpdate::AccountDeletion
Non-zero lamportsUpdate::AccountUpdate::Account

Consequences:

  • Account pipes (.account(..)) no longer receive zero-lamport accounts. If a processor handled closures by checking lamports == 0, move that logic to an .account_deletions(..) processor.
  • .account_deletions(..) processors now receive a deletion for every closed account the datasource sees, not only tracked ones. Filter by owner or pubkey in the processor if you only care about some.
  • AccountDeletion carries the final account state in a new account: Account field. Its owner and data are whatever the source reported for the closing update, so check them before you rely on them.

If you construct AccountDeletion yourself, add the field:

Rust
// Carbon 1.x
let deletion = AccountDeletion { pubkey, slot, transaction_signature: None };
Rust
// Carbon 2.0
let deletion = AccountDeletion { pubkey, account, slot, transaction_signature: None };

Custom datasources#

Build an AccountUpdate and call into_update() instead of choosing between Update::Account and Update::AccountDeletion yourself, so your datasource applies the same rule as the built-in ones:

Rust
// Carbon 1.x
let update = Update::Account(AccountUpdate { pubkey, account, slot, transaction_signature: None });
Rust
// Carbon 2.0
let update = AccountUpdate { pubkey, account, slot, transaction_signature: None }.into_update();

See Write a custom datasource.

Datasource configuration changes#

LaserStream config#

LaserStreamClientConfig::new takes a new last argument, from_slot: Option<u64>. Pass None to keep the previous behaviour, or a slot to start the stream from it.

Rust
// Carbon 1.x
let config = LaserStreamClientConfig::new(
    None,                              // compression
    Some(Duration::from_secs(15)),     // connect_timeout
    Some(Duration::from_secs(15)),     // timeout
    None,                              // max_decoding_message_size
    None,                              // tls_config
    None,                              // tcp_nodelay
    true,                              // replay_enabled
);
Rust
// Carbon 2.0
let config = LaserStreamClientConfig::new(
    None,                              // compression
    Some(Duration::from_secs(15)),     // connect_timeout
    Some(Duration::from_secs(15)),     // timeout
    None,                              // max_decoding_message_size
    None,                              // tls_config
    None,                              // tcp_nodelay
    true,                              // replay_enabled
    None,                              // from_slot
);

LaserStreamClientConfig::default() also works and sets from_slot: None.

Yellowstone client reconnection is opt-in#

YellowstoneGrpcClientConfig has a new reconnect field, off by default, so nothing changes unless you opt in. With it, the gRPC client reconnects and replays missed data inside the stream, and Carbon no longer sees (or reports) the disconnect. Use it only if your provider supports replay for the range you need:

Rust
// Carbon 2.0, optional; add yellowstone-grpc-client = "=13.5.0"
use yellowstone_grpc_client::ReconnectConfig;

let config = YellowstoneGrpcClientConfig::default().with_reconnect(ReconnectConfig::default());

If you build YellowstoneGrpcClientConfig with a struct literal, add reconnect: None. YellowstoneGrpcClientConfig::new(..) keeps its six arguments.

SPL Token account helpers#

carbon-token-program-decoder no longer has Mint::decode, Token::decode and Multisig::decode. Decoding through TokenProgramDecoder in a pipeline needs no change. If you called them directly, unpack with the SPL layout and convert:

Rust
// Carbon 1.x
let token = carbon_token_program_decoder::accounts::token::Token::decode(data);
Rust
// Carbon 2.0; add solana-program-pack = "3.1.0" and spl-token-interface = "3.0.0"
use solana_program_pack::Pack;

let token = spl_token_interface::state::Account::unpack(data)
    .ok()
    .map(carbon_token_program_decoder::accounts::token::Token::from);
let mint = spl_token_interface::state::Mint::unpack(data)
    .ok()
    .map(carbon_token_program_decoder::accounts::mint::Mint::from);
let multisig = spl_token_interface::state::Multisig::unpack(data)
    .ok()
    .map(carbon_token_program_decoder::accounts::multisig::Multisig::from);

unpack also checks that the account is initialized, which the 1.x decode (a length check plus borsh) didn't.

Transaction V1#

Agave 4.2 adds V1 transactions, and Carbon 2.0 decodes their instructions, inner instructions and account metadata like legacy and V0. Your code only changes if it matches on the message version: solana_message::VersionedMessage has a third variant.

Rust
// Carbon 2.0
match &input.metadata.transaction_metadata.message {
    VersionedMessage::Legacy(_) => "legacy",
    VersionedMessage::V0(_) => "v0",
    VersionedMessage::V1(_) => "v1",
}

Other behaviour changes#

These don't need code changes but can change what you see:

  • Prometheus metric names are sanitized. Characters other than letters, digits, _ and : become _ (rpc.block-crawler.fetch.failed is served as rpc_block_crawler_fetch_failed). Built-in names were already valid; update dashboards that query custom metrics with . or - in their names.
  • Short log events decode. decode_log_events no longer skips Program data: payloads shorter than 8 bytes, so events with short discriminators are returned.
  • Invalid CPI stack heights are skipped. Inner instructions with a missing or out-of-range stackHeight stop that inner-instruction group with a warning instead of producing a wrong tree.
  • Yellowstone backs off between failed subscribe attempts (100 ms doubling to 3 s) and keeps its filters on ping resubscribes.
  • Token Program accounts decode with the SPL Pack layout.

Verify the migration#

  1. cargo build with no duplicate Solana crates (cargo tree -d).
  2. Run against a known slot range and compare decoded counts with your 1.x indexer. Expect more AccountDeletions and fewer zero-lamport account updates.
  3. If you index V1 transactions, check that some arrive: VersionedMessage::V1 in transaction_metadata.message.

Common problems#

expected `solana_account::Account`, found a different `solana_account::Account` #

A Solana type crate you depend on is still on its 1.x-era major version (here solana-account 3), or a Carbon 1.x crate is left in the graph. Bump it to the version in the table and run cargo update. solana-pubkey 3 and 4 share one Pubkey type, so pubkeys rarely show this error; accounts, transactions and messages do.

this function takes 9 arguments but 10 arguments were supplied#

A 1.x YellowstoneGrpcGeyserClient::new call still passes account_deletions_tracked. Remove it. The same error with other counts comes from LaserStreamGeyserClient::new (7), HeliusWebsocket::new (3) and StreamMessageClient::new (1).

this function takes 8 arguments but 7 arguments were supplied on LaserStreamClientConfig::new#

Add the trailing from_slot argument (None).

no associated function or constant named `decode` found for struct `Token` #

Use spl_token_interface::state::Account::unpack and Token::from, as shown above.

Failed to decode transaction in the logs of an RPC block datasource#

Your config still requests Json/JsonParsed or version 0. Set UiTransactionEncoding::Base64 and max_supported_transaction_version: Some(1).

Next steps#

Your endpoints

Code samples use these instead of the placeholders, so you can copy and run them as they are. They’re stored only in this browser and never sent anywhere.