Callback Type Generation
One of the most frustrating parts of working with encrypted computations used to be manually parsing raw bytes in your callback functions. You'd have to remember exactly where each piece of data was located, what size it was, and how to convert it back to the right type. Not fun, and definitely error-prone.
The good news? Arcium now handles all of this for you automatically. When you write an encrypted instruction, the macro system analyzes what your function returns and generates perfectly typed Rust structs that you can use directly in your callbacks.
The Magic Behind the Scenes
Here's what happens when you define an encrypted instruction:
Arcium reads your circuit's output types
It generates corresponding Rust structs with predictable names
It automatically detects encryption patterns and creates specialized types
Everything gets integrated into your
#[arcium_callback]
functions
The best part? You never have to think about byte parsing again. Let's see how this works in practice.
Basic Example: Simple Addition
Consider this encrypted instruction that adds two numbers:
#[encrypted]
mod circuits {
use arcis_imports::*;
#[instruction]
pub fn add_together(input: Enc<Shared, (u8, u8)>) -> Enc<Shared, u16> {
let (a, b) = input.to_arcis();
let sum = a as u16 + b as u16;
input.owner.from_arcis(sum)
}
}
Behind the scenes, Arcium sees that your function returns Enc<Shared, u16>
and automatically generates this output struct for you:
#[derive(AnchorSerialize, AnchorDeserialize)]
pub struct AddTogetherOutput {
pub field_0: SharedEncryptedStruct<1>,
}
Notice how it detected that you're returning shared-encrypted data and created a SharedEncryptedStruct<1>
(the 1
means there's one encrypted value). Now you can use this directly in your callback:
#[arcium_callback(encrypted_ix = "add_together")]
pub fn add_together_callback(
ctx: Context<AddTogetherCallback>,
output: ComputationOutputs<AddTogetherOutput>,
) -> Result<()> {
let result = match output {
ComputationOutputs::Success(AddTogetherOutput { field_0 }) => field_0,
_ => return Err(ErrorCode::AbortedComputation.into()),
};
emit!(SumEvent {
sum: result.ciphertexts[0],
nonce: result.nonce.to_le_bytes(),
});
Ok(())
}
How the Naming Works
You might be wondering: "How do I know what the generated struct will be called?" Great question! The naming follows predictable patterns that make sense once you see them:
Your Circuit Gets an Output Struct
If your encrypted instruction is called add_together
, you get a struct called AddTogetherOutput
. Simple! Arcium just takes your circuit name and converts it to PascalCase, then adds "Output" at the end.
Fields Are Numbered
Since Anchor doesn't support tuple structs (yet), Arcium uses numbered fields instead. So if your function returns multiple values, you'll get field_0
, field_1
, field_2
, and so on. Not the prettiest names, but they're consistent and predictable.
Complex Types Get Their Own Structs
When your function returns complex nested data (like tuples or custom structs), Arcium generates additional helper structs:
Regular structs become
{CircuitName}OutputStruct{number}
Tuples become
{CircuitName}TupleStruct{number}
Nested stuff gets even longer names to avoid conflicts
The Really Cool Part: Automatic Encryption Detection
Here's where things get really neat. Arcium doesn't just generate basic structs - it's smart enough to recognize when you're working with encrypted data and creates specialized types that make your life easier.
SharedEncryptedStruct<N>
When your circuit returns Enc<Shared, T>
, Arcium knows this is data that both the client and the MXE can decrypt. It generates a struct that includes everything needed for decryption:
pub struct SharedEncryptedStruct<const LEN: usize> {
pub encryption_key: [u8; 32], // The shared public key
pub nonce: u128, // Random nonce for security
pub ciphertexts: [[u8; 32]; LEN], // Your actual encrypted data
}
The <N>
part tells you how many encrypted values are packed inside. So SharedEncryptedStruct<1>
has one encrypted value, SharedEncryptedStruct<3>
has three, and so on.
In your callback, you can access everything you need:
let shared_key = result.encryption_key; // For key exchange
let nonce = result.nonce; // For decryption
let encrypted_value = result.ciphertexts[0]; // Your data
MXEEncryptedStruct<N>
For Enc<Mxe, T>
data, only the MXE cluster can decrypt it - clients can't. Since there's no shared secret needed, the struct is simpler:
pub struct MXEEncryptedStruct<const LEN: usize> {
pub nonce: u128, // Still need the nonce
pub ciphertexts: [[u8; 32]; LEN], // Your encrypted data
}
Notice there's no encryption_key
field here - that's because clients don't get to decrypt MXE data.
// Working with MXE-encrypted data
let nonce = result.nonce;
let encrypted_value = result.ciphertexts[0];
// Note: You can't decrypt this on the client side!
EncDataStruct<N>
For simple encrypted data without key exchange metadata:
// Pattern: Only N Ciphertexts
pub struct EncDataStruct<const LEN: usize> {
pub ciphertexts: [[u8; 32]; LEN], // Raw encrypted values
}
Let's See This in Action
Nothing beats real examples! Let's look at how this type generation works in actual Arcium applications that people are building:
Voting Application
The confidential voting example shows a perfect use case. You have poll data that only the MXE should see, and a user's vote that should be shared between the user and the MXE:
#[instruction]
pub fn vote(
poll_data: Enc<Mxe, &PollData>, // Poll results stay private
vote_choice: Enc<Shared, u8> // User can verify their vote
) -> (Enc<Mxe, PollData>, Enc<Shared, bool>) {
// ... voting logic that maintains privacy
}
Since this function returns a tuple (Enc<Mxe, PollData>, Enc<Shared, bool>)
, Arcium generates:
pub struct VoteOutput {
pub field_0: VoteTupleStruct0, // The whole tuple wraps into one field
}
pub struct VoteTupleStruct0 {
pub field_0: MXEEncryptedStruct<N>, // The updated poll data
pub field_1: SharedEncryptedStruct<1>, // The vote confirmation
}
Now in your callback, you can work with properly typed data instead of raw bytes:
#[arcium_callback(encrypted_ix = "vote")]
pub fn vote_callback(
ctx: Context<VoteCallback>,
output: ComputationOutputs<VoteOutput>,
) -> Result<()> {
let VoteOutput { field_0 } = match output {
ComputationOutputs::Success(result) => result,
_ => return Err(ErrorCode::AbortedComputation.into()),
};
let poll_data = field_0.field_0; // The updated poll (MXE only)
let vote_confirmation = field_0.field_1; // User's confirmation (shared)
// Emit an event with the user's confirmation
emit!(VoteEvent {
confirmation: vote_confirmation.ciphertexts[0],
nonce: vote_confirmation.nonce.to_le_bytes(),
});
Ok(())
}
Coinflip Application
The coinflip example is beautifully simple - just a function that returns a random boolean:
#[instruction]
pub fn flip() -> Enc<Shared, bool> {
// Generate secure randomness in MPC
// Return encrypted result that client can decrypt
}
Arcium sees this returns Enc<Shared, bool>
and creates:
pub struct FlipOutput {
pub field_0: SharedEncryptedStruct<1>, // Just one boolean
}
Your callback becomes super clean:
#[arcium_callback(encrypted_ix = "flip")]
pub fn flip_callback(
ctx: Context<FlipCallback>,
output: ComputationOutputs<FlipOutput>,
) -> Result<()> {
let result = match output {
ComputationOutputs::Success(FlipOutput { field_0 }) => field_0,
_ => return Err(ErrorCode::AbortedComputation.into()),
};
// Emit the encrypted result - client will decrypt to see heads/tails
emit!(FlipEvent {
result: result.ciphertexts[0],
nonce: result.nonce.to_le_bytes(),
});
Ok(())
}
Blackjack Application
From the blackjack example with complex game state:
#[instruction]
pub fn player_hit(
game_state: Enc<Mxe, &GameState>,
player_hand: Enc<Shared, PlayerHand>
) -> (Enc<Mxe, GameState>, Enc<Shared, PlayerHand>, Enc<Shared, bool>) {
// ... game logic
}
Generated types:
pub struct PlayerHitOutput {
pub field_0: PlayerHitTupleStruct0,
}
pub struct PlayerHitTupleStruct0 {
pub field_0: MXEEncryptedStruct<N>, // Updated game state
pub field_1: SharedEncryptedStruct<M>, // Player's new hand
pub field_2: SharedEncryptedStruct<1>, // Is game over?
}
Complex Nested Structures
For more complex outputs with nested data structures:
pub struct UserData {
id: u32,
active: bool,
}
#[instruction]
pub fn complex_example() -> (
UserData,
Enc<Shared, u32>,
(u64, f32),
Enc<Mxe, bool>
) {
// ... complex logic
}
Generated types:
pub struct ComplexExampleOutput {
pub field_0: ComplexExampleTupleStruct0, // Entire tuple as single field
}
pub struct ComplexExampleTupleStruct0 {
pub field_0: ComplexExampleTupleStruct0OutputStruct0, // UserData
pub field_1: SharedEncryptedStruct<1>, // Enc<Shared, u32>
pub field_2: ComplexExampleTupleStruct0TupleStruct02, // (u64, f32) tuple
pub field_3: MXEEncryptedStruct<1>, // Enc<Mxe, bool>
}
pub struct ComplexExampleTupleStruct0OutputStruct0 {
pub field_0: u32, // UserData.id
pub field_1: bool, // UserData.active
}
pub struct ComplexExampleTupleStruct0TupleStruct02 {
pub field_0: u64, // First tuple element
pub field_1: f32, // Second tuple element
}
Working with Generated Types
Pattern Matching
Use destructuring to access nested data:
let ComplexExampleOutput {
field_0: ComplexExampleTupleStruct0 {
field_0: user_data,
field_1: shared_encrypted,
field_2: tuple_data,
field_3: mxe_encrypted,
}
} = match output {
ComputationOutputs::Success(result) => result,
_ => return Err(ErrorCode::AbortedComputation.into()),
};
// Access specific fields
let user_id = user_data.field_0;
let is_active = user_data.field_1;
let shared_value = shared_encrypted.ciphertexts[0];
let timestamp = tuple_data.field_0;
Error Handling
Always handle computation failures:
let result = match output {
ComputationOutputs::Success(data) => data,
ComputationOutputs::Failure(error) => {
msg!("Computation failed: {:?}", error);
return Err(ErrorCode::AbortedComputation.into());
}
ComputationOutputs::Timeout => {
msg!("Computation timed out");
return Err(ErrorCode::ComputationTimeout.into());
}
};
Best Practices
1. Use Descriptive Variable Names
// Good
let FlipOutput { field_0: coin_result } = result;
let is_heads = coin_result.ciphertexts[0];
// Less clear
let FlipOutput { field_0 } = result;
let result = field_0.ciphertexts[0];
2. Document Your Circuit Interfaces
/// Returns (updated_game_state, player_hand, is_game_over)
#[instruction]
pub fn player_hit(/* ... */) -> (Enc<Mxe, GameState>, Enc<Shared, PlayerHand>, Enc<Shared, bool>) {
// ...
}
3. Handle All Computation States
let result = match output {
ComputationOutputs::Success(data) => data,
ComputationOutputs::Failure(_) => return Err(ErrorCode::AbortedComputation.into()),
ComputationOutputs::Timeout => return Err(ErrorCode::ComputationTimeout.into()),
};
4. Emit Events for Client Tracking
emit!(ComputationCompleteEvent {
computation_id: ctx.accounts.computation_account.key(),
success: true,
result_hash: hash(&result.ciphertexts[0]),
});
When Things Go Wrong
Don't worry, we've all been there! Here are the most common issues you'll run into and how to fix them:
"Type not found" Errors
// Error: cannot find type `MyCircuitOutput` in this scope
output: ComputationOutputs<MyCircuitOutput>
This usually means one of two things:
Typo in the circuit name - Check that
MyCircuit
exactly matches your#[instruction]
function name (case matters!)You forgot to rebuild - Run
arcium build
again after making changes to your encrypted instructions
"No field found" Errors
// Error: no field `result` on type `AddTogetherOutput`
let value = output.result;
Remember, the generated structs use numbered fields like field_0
, field_1
, etc. There's no field called result
unless you specifically named your function that way.
Try this instead:
let value = output.field_0; // First (and often only) field
Encryption Type Mismatches
// Error: expected `SharedEncryptedStruct<1>`, found `MXEEncryptedStruct<1>`
This happens when your circuit returns Enc<Mxe, T>
but your callback expects Enc<Shared, T>
(or vice versa). Double-check your encrypted instruction's return type - it needs to match what you're expecting in the callback.
Debugging Generated Types
To see what types are generated for your circuit, check the build output or use cargo expand
in your program directory:
# First install cargo-expand if you haven't already
cargo install cargo-expand
# Then use it to see the generated code
cd programs/your-program
cargo expand > expanded.rs
# Search for your circuit name in expanded.rs
Migration from v0.1.x
If you're upgrading from an older version, the new type generation system replaces manual byte parsing:
Old way (v0.1.x):
pub fn callback(output: ComputationOutputs) -> Result<()> {
let bytes = if let ComputationOutputs::Bytes(bytes) = output {
bytes
} else {
return Err(ErrorCode::AbortedComputation.into());
};
let sum = bytes[48..80].try_into().unwrap();
let nonce = bytes[32..48].try_into().unwrap();
// ...
}
New way (v0.2.0+):
pub fn callback(output: ComputationOutputs<AddTogetherOutput>) -> Result<()> {
let AddTogetherOutput { field_0 } = match output {
ComputationOutputs::Success(result) => result,
_ => return Err(ErrorCode::AbortedComputation.into()),
};
let sum = field_0.ciphertexts[0];
let nonce = field_0.nonce;
// ...
}
For detailed migration steps, see the Migration Guide.
And that's it! The callback type generation system takes all the tedious work out of handling encrypted computation results. No more manual byte parsing, no more wondering if you got the offsets right, and no more runtime surprises when your data doesn't match what you expected.
With these automatically generated types, you can focus on building amazing privacy-preserving applications instead of wrestling with low-level data handling. Pretty neat, right?
Last updated