The program implements specific instructions for delegating and undelegating the counter:
Delegate: Delegates counter from Base Layer to ER (called on Base Layer)
CommitAndUndelegate: Schedules sync of counter from ER to Base Layer, and undelegates counter on ER (called on ER)
Commit: Schedules sync of counter from ER to Base Layer (called on ER)
Undelegate:
Schedules sync and undelegation of counter (called on ER)
Undelegation triggered through callback instruction injected through #[ephemeral] (called on Base Layer through validator CPI)
The undelegation callback discriminator [196, 28, 41, 206, 48, 37, 51, 167]
and its instruction processor must be specified in your program. This
instruction triggered by Delegation Program reverts account ownership on the
Base Layer after calling undelegation on ER.With [#ephemeral] Anchor macro from MagicBlock’s Ephemeral Rollup SDK, the undelegation callback discriminator and processor are injected into your program.
#[ephemeral]#[program]pub mod public_counter { use super::*; /// Initialize the counter. pub fn initialize(ctx: Context<Initialize>) -> Result<()> { let counter = &mut ctx.accounts.counter; counter.count = 0; Ok(()) } /// Increment the counter. pub fn increment(ctx: Context<Increment>) -> Result<()> { let counter = &mut ctx.accounts.counter; counter.count += 1; Ok(()) } /// Delegate the account to the delegation program /// Set specific validator based on ER, see https://docs.magicblock.gg/pages/get-started/how-integrate-your-program/local-setup pub fn delegate(ctx: Context<DelegateInput>) -> Result<()> { // ... } /// Manually commit the counter state in the Ephemeral Rollup session. pub fn commit(ctx: Context<IncrementAndCommit>) -> Result<()> { // ... } /// Increment the counter and commit in the same instruction. pub fn increment_and_commit(ctx: Context<IncrementAndCommit>) -> Result<()> { // ... } /// Undelegate the account from the delegation program. pub fn undelegate(ctx: Context<IncrementAndCommit>) -> Result<()> { // ... }}pub const COUNTER_SEED: &[u8] = b"counter";/// Context for initializing counter#[derive(Accounts)]pub struct Initialize<'info> { #[account(init_if_needed, payer = user, space = 8 + 8, seeds = [COUNTER_SEED], bump)] pub counter: Account<'info, Counter>, #[account(mut)] pub user: Signer<'info>, pub system_program: Program<'info, System>,}/// Context for incrementing counter#[derive(Accounts)]pub struct Increment<'info> { #[account(mut, seeds = [COUNTER_SEED], bump)] pub counter: Account<'info, Counter>,}/// Counter struct#[account]pub struct Counter { pub count: u64,}/// Other context for delegation
Nothing special here, just a simple Anchor program that increments a counter. The only difference is that we’re adding the ephemeral macro for undelegation and delegate macro to inject some useful logic to interact with the delegation program.⬆️ Back to Top
These public validators are supported for development. Make sure to add the
specific ER validator in your delegation instruction:
Mainnet
Asia (as.magicblock.app):
MAS1Dt9qreoRMQ14YQuhg8UTZMMzDdKhmkZMECCzk57
EU (eu.magicblock.app):
MEUGGrYPxKk17hCr7wpT6s8dtNokZj5U2L57vjYMS8e
US (us.magicblock.app):
MUS3hc9TCw4cGC12vHNoYcCGzJG1txjgQLZWVoeNHNd
TEE (mainnet-tee.magicblock.app):
MTEWGuqxUpYZGFJQcp8tLN7x5v9BSeoFHYWQQ3n3xzo
Devnet
Asia (devnet-as.magicblock.app):
MAS1Dt9qreoRMQ14YQuhg8UTZMMzDdKhmkZMECCzk57
EU (devnet-eu.magicblock.app):
MEUGGrYPxKk17hCr7wpT6s8dtNokZj5U2L57vjYMS8e
US (devnet-us.magicblock.app):
MUS3hc9TCw4cGC12vHNoYcCGzJG1txjgQLZWVoeNHNd
TEE (devnet-tee.magicblock.app):
MTEWGuqxUpYZGFJQcp8tLN7x5v9BSeoFHYWQQ3n3xzo
Localnet
Local ER (localhost:7799):
mAGicPQYBMvcYveUZA5F5UNNwyHvfYh5xkLS2Fr1mev
Add ephemeral-rollups-sdk with Anchor features to your program
cargo add ephemeral-rollups-sdk --features anchor
Import delegate, commit, ephemeral, DelegateConfig, and MagicIntentBundleBuilder (which replaces the deprecated commit_accounts and commit_and_undelegate_accounts helpers):
use ephemeral_rollups_sdk::anchor::{ commit, delegate, ephemeral};use ephemeral_rollups_sdk::cpi::DelegateConfig;use ephemeral_rollups_sdk::ephem::MagicIntentBundleBuilder;
Add delegate macro and instruction, ephemeral macro, and undelegate instruction to your program. Specify your preferred delegation config such as auto commits and specific ER validator:
/// Add delegate function to the context#[delegate]#[derive(Accounts)]pub struct DelegateInput<'info> { pub payer: Signer<'info>, /// CHECK: The pda to delegate #[account(mut, del)] pub pda: AccountInfo<'info>,}
/// Delegate the account to the delegation program/// Set specific validator based on ER, see https://docs.magicblock.gg/pages/get-started/how-integrate-your-program/local-setuppub fn delegate(ctx: Context<DelegateInput>) -> Result<()> { ctx.accounts.delegate_pda( &ctx.accounts.payer, &[COUNTER_SEED], DelegateConfig { // Optionally set a specific validator from the first remaining account validator: ctx.remaining_accounts.first().map(|acc| acc.key()), ..Default::default() }, )?; Ok(())}
use ephemeral_rollups_sdk::ephem::MagicIntentBundleBuilder;/// Manually commit the counter state in the Ephemeral Rollup session.pub fn commit(ctx: Context<IncrementAndCommit>) -> Result<()> { MagicIntentBundleBuilder::new( ctx.accounts.payer.to_account_info(), ctx.accounts.magic_context.to_account_info(), ctx.accounts.magic_program.to_account_info(), ) .commit(&[ctx.accounts.counter.to_account_info()]) .build_and_invoke()?; Ok(())}/// Increment the counter and commit the new state in the same instruction.pub fn increment_and_commit(ctx: Context<IncrementAndCommit>) -> Result<()> { let counter = &mut ctx.accounts.counter; counter.count += 1; // Serialize the Anchor account before the CPI sees it counter.exit(&crate::ID)?; MagicIntentBundleBuilder::new( ctx.accounts.payer.to_account_info(), ctx.accounts.magic_context.to_account_info(), ctx.accounts.magic_program.to_account_info(), ) .commit(&[ctx.accounts.counter.to_account_info()]) .build_and_invoke()?; Ok(())}
use ephemeral_rollups_sdk::ephem::MagicIntentBundleBuilder;/// Undelegate the account from the delegation program./// Commits the latest state and returns ownership of the PDA back to the owner program.pub fn undelegate(ctx: Context<IncrementAndCommit>) -> Result<()> { MagicIntentBundleBuilder::new( ctx.accounts.payer.to_account_info(), ctx.accounts.magic_context.to_account_info(), ctx.accounts.magic_program.to_account_info(), ) .commit_and_undelegate(&[ctx.accounts.counter.to_account_info()]) .build_and_invoke()?; Ok(())}
Delegation is the process of transferring ownership of one or more of your program’s PDAs to the delegation program. Ephemeral Validators will then be able to use the PDAs to perform transactions in the SVM runtime.
Commit is the process of updating the state of the PDAs from ER to the base layer. After the finalization process, the PDAs remain locked on base layer.
Undelegation is the process of transferring ownership of the PDAs back to your program. On undelegation, the state is committed and it trigger the finalization process. Once state it validated, the PDAs are unlocked and can be used as normal on base layer.
Ready to execute transactions for delegation and real-time speed.
These public validators are supported for development. Make sure to add the
specific ER validator in your delegation instruction:
Mainnet
Asia (as.magicblock.app):
MAS1Dt9qreoRMQ14YQuhg8UTZMMzDdKhmkZMECCzk57
EU (eu.magicblock.app):
MEUGGrYPxKk17hCr7wpT6s8dtNokZj5U2L57vjYMS8e
US (us.magicblock.app):
MUS3hc9TCw4cGC12vHNoYcCGzJG1txjgQLZWVoeNHNd
TEE (mainnet-tee.magicblock.app):
MTEWGuqxUpYZGFJQcp8tLN7x5v9BSeoFHYWQQ3n3xzo
Devnet
Asia (devnet-as.magicblock.app):
MAS1Dt9qreoRMQ14YQuhg8UTZMMzDdKhmkZMECCzk57
EU (devnet-eu.magicblock.app):
MEUGGrYPxKk17hCr7wpT6s8dtNokZj5U2L57vjYMS8e
US (devnet-us.magicblock.app):
MUS3hc9TCw4cGC12vHNoYcCGzJG1txjgQLZWVoeNHNd
TEE (devnet-tee.magicblock.app):
MTEWGuqxUpYZGFJQcp8tLN7x5v9BSeoFHYWQQ3n3xzo
Localnet
Local ER (localhost:7799):
mAGicPQYBMvcYveUZA5F5UNNwyHvfYh5xkLS2Fr1mev
anchor test --skip-build --skip-deploy --skip-local-validator
Run the following test:
const COUNTER_SEED = "counter";// Set Anchor providersconst provider = new anchor.AnchorProvider( new anchor.web3.Connection( process.env.PROVIDER_ENDPOINT || "https://api.devnet.solana.com", { wsEndpoint: process.env.PROVIDER_WS_ENDPOINT || undefined, commitment: "confirmed", }, ), anchor.Wallet.local(),);anchor.setProvider(provider);const providerEphemeralRollup = new anchor.AnchorProvider( new anchor.web3.Connection( process.env.EPHEMERAL_PROVIDER_ENDPOINT || "https://devnet-as.magicblock.app/", { wsEndpoint: process.env.EPHEMERAL_WS_ENDPOINT || "wss://devnet-as.magicblock.app/", commitment: "confirmed", }, ), anchor.Wallet.local(),);// Set program and PDAconst program = anchor.workspace.PublicCounter as Program<PublicCounter>;const [counterPDA] = anchor.web3.PublicKey.findProgramAddressSync( [Buffer.from(COUNTER_SEED)], program.programId,);// Initialize counter on base layerlet initTx = await program.methods .initialize() .accounts({ user: provider.wallet.publicKey, }) .transaction();const initTxHash = await provider.sendAndConfirm(initTx, [ provider.wallet.payer,]);// Increment counter on base layerlet incBaseTx = await program.methods .increment() .accounts({ counter: counterPDA, }) .transaction();const incBaseTxHash = await provider.sendAndConfirm(incBaseTx, [ provider.wallet.payer,]);// Delegate counter to ER// Pin a specific validator by passing it in remaining_accountsconst ER_VALIDATOR = new anchor.web3.PublicKey( "MAS1Dt9qreoRMQ14YQuhg8UTZMMzDdKhmkZMECCzk57", // Asia ER validator);let delTx = await program.methods .delegate() .accounts({ payer: provider.wallet.publicKey, pda: counterPDA, }) .remainingAccounts([ { pubkey: ER_VALIDATOR, isSigner: false, isWritable: false }, ]) .transaction();const delTxHash = await provider.sendAndConfirm(delTx, [ provider.wallet.payer,]);// Increment counter in real time on ERlet incErTx = await program.methods .increment() .accounts({ counter: counterPDA, }) .transaction();incErTx.feePayer = providerEphemeralRollup.wallet.publicKey;incErTx.recentBlockhash = ( await providerEphemeralRollup.connection.getLatestBlockhash()).blockhash;incErTx = await providerEphemeralRollup.wallet.signTransaction(incErTx);const incErTxHash = await providerEphemeralRollup.sendAndConfirm(incErTx);// Commit and undelegate counter from ER back to base layerlet undelTx = await program.methods .undelegate() .accounts({ payer: providerEphemeralRollup.wallet.publicKey, }) .transaction();undelTx.feePayer = providerEphemeralRollup.wallet.publicKey;undelTx.recentBlockhash = ( await providerEphemeralRollup.connection.getLatestBlockhash()).blockhash;undelTx = await providerEphemeralRollup.wallet.signTransaction(undelTx);const undelTxHash = await providerEphemeralRollup.sendAndConfirm(undelTx);
To make it easier to integrate via the frontend, we created the Magic Router. You send transactions directly to the magic router, and we can determine for you whether it should be routed to the Ephemeral Rollup or base layer.
Attach one or more instructions that run automatically on the Solana base layer immediately after an Ephemeral Rollup
(ER) commit.
Learn more about Magic Action
The instruction update_leaderboard runs on the base layer immediately after the commit lands. The #[action] attribute on its accounts context marks it as callable from a post-commit action.
// program instructionpub fn update_leaderboard(ctx: Context<UpdateLeaderboard>) -> Result<()> { let leaderboard = &mut ctx.accounts.leaderboard; let counter_info = &mut ctx.accounts.counter.to_account_info(); let mut data: &[u8] = &counter_info.try_borrow_data()?; let counter = Counter::try_deserialize(&mut data)?; if counter.count > leaderboard.high_score { leaderboard.high_score = counter.count; } msg!( "Leaderboard updated! High score: {}", leaderboard.high_score ); Ok(())}// instruction context#[action]#[derive(Accounts)]pub struct UpdateLeaderboard<'info> { #[account(mut, seeds = [LEADERBOARD_SEED], bump)] pub leaderboard: Account<'info, Leaderboard>, /// CHECK: PDA owner depends on: 1) Delegated: Delegation Program; 2) Undelegated: Your program ID pub counter: UncheckedAccount<'info>,}
The commit instruction commit_and_update_leaderboard runs on the ER. It uses MagicIntentBundleBuilder to schedule both the commit and the post-commit action onto magic_context — both are applied together when the ER transaction is sealed back to the base layer.
// commit action instruction on ERpub fn commit_and_update_leaderboard(ctx: Context<CommitAndUpdateLeaderboard>) -> Result<()> { // Build the post-commit action that updates the leaderboard on base layer let instruction_data = anchor_lang::InstructionData::data(&crate::instruction::UpdateLeaderboard {}); let action_args = ActionArgs::new(instruction_data); let action_accounts = vec![ ShortAccountMeta { pubkey: ctx.accounts.leaderboard.key(), is_writable: true, }, ShortAccountMeta { pubkey: ctx.accounts.counter.key(), is_writable: false, }, ]; let action = CallHandler { destination_program: crate::ID, accounts: action_accounts, args: action_args, // Signer that pays transaction fees for the action from its escrow PDA escrow_authority: ctx.accounts.payer.to_account_info(), compute_units: 200_000, }; // Schedule commit + post-commit action on magic_context MagicIntentBundleBuilder::new( ctx.accounts.payer.to_account_info(), ctx.accounts.magic_context.to_account_info(), ctx.accounts.magic_program.to_account_info(), ) .commit(&[ctx.accounts.counter.to_account_info()]) .add_post_commit_actions([action]) .build_and_invoke()?; Ok(())}// commit action context on ER#[commit]#[derive(Accounts)]pub struct CommitAndUpdateLeaderboard<'info> { #[account(mut)] pub payer: Signer<'info>, #[account(mut, seeds = [COUNTER_SEED], bump)] pub counter: Account<'info, Counter>, /// CHECK: Leaderboard PDA - not mut here, writable set in handler #[account(seeds = [LEADERBOARD_SEED], bump)] pub leaderboard: UncheckedAccount<'info>, /// CHECK: Your program ID pub program_id: AccountInfo<'info>,}
You can commit multiple accounts and chain several actions in one call. Actions execute sequentially in the order they’re passed to add_post_commit_actions.
// Chain several actions — they execute sequentially on base layer after the commit lands.MagicIntentBundleBuilder::new( ctx.accounts.payer.to_account_info(), ctx.accounts.magic_context.to_account_info(), ctx.accounts.magic_program.to_account_info(),).commit(&[ ctx.accounts.counter.to_account_info(), // ... additional committed accounts]).add_post_commit_actions([action_1, action_2, action_3]).build_and_invoke()?;
Actions can also be chained onto an undelegation — the counter commits, undelegates, and the actions run, all atomically in one ER transaction.
// Commit, undelegate, AND execute actions — all atomically on base layer after the ER transaction seals.MagicIntentBundleBuilder::new( ctx.accounts.payer.to_account_info(), ctx.accounts.magic_context.to_account_info(), ctx.accounts.magic_program.to_account_info(),).commit_and_undelegate(&[ctx.accounts.counter.to_account_info()]).add_post_commit_actions([action]).build_and_invoke()?;
Top up a delegated account’s lamports on the ER side. The transaction is submitted on the base layer and uses the Ephemeral SPL Token program to shuttle lamports to the destination’s delegated balance via a single-use lamports PDA.Common use case: keeping a delegated fee payer funded so it can keep paying its own commits past the default 10-commit sponsorship cap.Notes:
Generate a fresh 32-byte salt per top-up via crypto.getRandomValues — re-using a salt collides with an existing PDA.
Submit to the base-layer RPC, not the ER.
The destination must already be delegated.
import { Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction,} from "@solana/web3.js";import { lamportsDelegatedTransferIx, deriveLamportsPda,} from "@magicblock-labs/ephemeral-rollups-sdk";/** * Top up a delegated account with lamports. * * The transaction is submitted on the BASE LAYER. The Ephemeral SPL Token * program creates a single-use lamports PDA, funds it from the payer, and * delegates it so the ER credits the destination's delegated balance. */async function topUpDelegatedAccount( connection: Connection, // base-layer connection payer: Keypair, destination: PublicKey, // delegated account to top up amountLamports: bigint,) { // Generate a fresh 32-byte salt per top-up. // Re-using a salt collides with an existing lamports PDA and the call fails. const salt = crypto.getRandomValues(new Uint8Array(32)); const [lamportsPda] = deriveLamportsPda(payer.publicKey, destination, salt); const ix = await lamportsDelegatedTransferIx( payer.publicKey, destination, amountLamports, salt, ); const tx = new Transaction().add(ix); tx.feePayer = payer.publicKey; // CRITICAL: send to the base-layer RPC, not the ER. const sig = await sendAndConfirmTransaction(connection, tx, [payer], { commitment: "confirmed", skipPreflight: true, }); return { sig, lamportsPda };}