When you write encrypted instructions in Arcium, the results come back as structured data. Previously, developers had to manually parse raw bytes - tracking offsets, sizes, and converting back to the right types. This was error-prone and tedious.Arcium’s type generation system analyzes your circuit’s return type and automatically creates typed Rust structs. This means you can work directly with structured data instead of byte arrays.
Here’s a concrete example. Consider this encrypted instruction that adds two numbers:
#[encrypted]mod circuits { use arcis::*; #[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) }}
When Arcium sees that your function returns Enc<Shared, u16>, it automatically generates this output struct:
You must run arcium build before compiling your program so the .idarc files exist for the macro to read. Run arcium build again whenever you change an encrypted instruction’s return type.
// Available after running arcium build#[arcium_callback(encrypted_ix = "add_together")]pub fn callback( ctx: Context<AddTogetherCallback>, output: SignedComputationOutputs<AddTogetherOutput>,) -> Result<()> { // AddTogetherOutput was generated by #[callback_accounts] from build/add_together.idarc}
Type safety: You get compile-time type checking for encrypted results
No manual definition: You don’t need to define output structs yourself
Consistency: All generated types follow the same predictable patterns
Automatic updates: If you change your function’s return type, re-run arcium build and the structs update automatically
The key insight is that these structs exist in your compiled program but not in your source code — arcium build produces the interface files, and #[callback_accounts] turns them into Rust types during compilation.
In our add_together example, you saw SharedEncryptedStruct<1>. The <LEN> number tells you how many encrypted scalar values are stored inside.The <LEN> number represents the count of individual encrypted scalar values:
Return Type
LEN Value
Why
Enc<Shared, u32>
1
Single scalar
Enc<Shared, (u32, bool)>
2
Two scalars
Enc<Shared, [u32; 5]>
5
Five array elements
Enc<Shared, MyStruct>
field count
Count all scalar fields in struct
For custom structs, LEN equals total scalar fields:
Generated types are scoped to the module where #[callback_accounts] is used. After running arcium build, the types become available during program compilation:
// In your Solana program — CalculateOutput is generated by #[callback_accounts]// which reads build/calculate.idarc (produced by arcium build from your #[instruction] function)#[callback_accounts("calculate")]pub struct CalculateCallback<'info> { /* ... */ }#[arcium_callback(encrypted_ix = "calculate")]pub fn calculate_callback( ctx: Context<CalculateCallback>, output: SignedComputationOutputs<CalculateOutput>,) -> Result<()> { // CalculateOutput is available here}
If your encrypted instruction is called add_together, you get a struct called AddTogetherOutput. Arcium converts your circuit name to PascalCase and adds “Output” at the end.
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.
When your function returns complex nested data (like tuples or custom structs), Arcium generates additional helper structs with a unified naming convention:
All output structs use {CircuitName}OutputStruct{index} pattern
Nested structs within outputs use {ParentName}OutputStruct{parent_index}{field_index} pattern
The naming ensures uniqueness while maintaining consistency
Arcium automatically detects different encryption patterns and generates the right struct type. Understanding when each type is used helps you predict the generated structs.
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 exchangelet nonce = result.nonce; // For decryptionlet encrypted_value = result.ciphertexts[0]; // Your data
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 datalet nonce = result.nonce;let encrypted_value = result.ciphertexts[0];// Note: You can't decrypt this on the client side!
For encrypted data without key exchange metadata (used when the observer tracks encryption context client-side):
// Pattern: Only N Ciphertextspub struct EncDataStruct<const LEN: usize> { pub ciphertexts: [[u8; 32]; LEN], // Raw encrypted values}
Note: EncDataStruct<N> is used when only ciphertext data is needed without additional metadata. See EncData<T> for when to use this pattern. Most applications use SharedEncryptedStruct<N> or MXEEncryptedStruct<N> instead.
Now that you understand the basics with our simple addition example, here’s how this works in real applications. The key difference is that real apps often:
Return multiple values: Functions return tuples or complex structs instead of single values
Mix encryption types: Some data for users (Shared), some for MXE only (Mxe)
Handle complex data: Custom structs with multiple fields instead of simple numbers
The type generation system handles all of this automatically - you just need to understand the patterns.
Let’s start with something in between - a function that returns two related values:
#[instruction]pub fn calculate_stats(value: u32) -> (Enc<Shared, u32>, Enc<Shared, u32>) { // Calculate both the square and double of a number (value.square(), value * 2)}
Since this returns a tuple (Enc<Shared, u32>, Enc<Shared, u32>), Arcium generates:
pub struct CalculateStatsOutput { pub field_0: CalculateStatsOutputStruct0, // The whole tuple becomes one field}pub struct CalculateStatsOutputStruct0 { pub field_0: SharedEncryptedStruct<1>, // First u32 (the square) pub field_1: SharedEncryptedStruct<1>, // Second u32 (the double)}
Notice how tuples get wrapped: the tuple itself becomes field_0, and its elements become field_0, field_1, etc.
Now let’s look at a more realistic example. 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:
// Example poll data structure (would be defined in your program)#[derive(AnchorSerialize, AnchorDeserialize)]pub struct PollData { pub vote_count_yes: u32, pub vote_count_no: u32, pub is_active: bool,}#[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: VoteOutputStruct0, // The whole tuple wraps into one field}pub struct VoteOutputStruct0 { pub field_0: MXEEncryptedStruct<3>, // The updated poll data (vote_count_yes + vote_count_no + is_active = 3) pub field_1: SharedEncryptedStruct<1>, // The vote confirmation (boolean)}
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: SignedComputationOutputs<VoteOutput>,) -> Result<()> { let o = match output.verify_output( &ctx.accounts.cluster_account, &ctx.accounts.computation_account ) { Ok(VoteOutput { field_0 }) => field_0, Err(_) => return Err(ErrorCode::AbortedComputation.into()), }; let poll_data = o.field_0; // The updated poll (MXE only) let vote_confirmation = o.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(())}
After seeing complex tuples and mixed encryption types, let’s look at the simplest possible case. The coinflip example returns just a single encrypted 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:
#[arcium_callback(encrypted_ix = "flip")]pub fn flip_callback( ctx: Context<FlipCallback>, output: SignedComputationOutputs<FlipOutput>,) -> Result<()> { let o = match output.verify_output( &ctx.accounts.cluster_account, &ctx.accounts.computation_account ) { Ok(FlipOutput { field_0 }) => field_0, Err(_) => return Err(ErrorCode::AbortedComputation.into()), }; // Emit the encrypted result - client will decrypt to see heads/tails emit!(FlipEvent { result: o.ciphertexts[0], nonce: o.nonce.to_le_bytes(), }); Ok(())}
Notice the naming pattern: We have ComplexExampleOutputStruct00 and ComplexExampleOutputStruct02, but no ComplexExampleOutputStruct01. This is because:
field_0 (UserData) needs a custom struct → ComplexExampleOutputStruct00
field_1 (SharedEncryptedStruct) uses a predefined type → no custom struct needed
field_2 ((u64, f32) tuple) needs a custom struct → ComplexExampleOutputStruct02
field_3 (MXEEncryptedStruct) uses a predefined type → no custom struct needed
Only fields that contain custom structs or tuples get their own generated struct definitions.
let result = match output.verify_output( &ctx.accounts.cluster_account, &ctx.accounts.computation_account,) { Ok(data) => data, Err(_) => return Err(ErrorCode::AbortedComputation.into()),};
let result = match output.verify_output( &ctx.accounts.cluster_account, &ctx.accounts.computation_account,) { Ok(data) => data, Err(_) => return Err(ErrorCode::AbortedComputation.into()),};
emit!(ComputationCompleteEvent { computation_id: ctx.accounts.computation_account.key(), success: true, result_hash: result.ciphertexts[0], // or use a hash function if needed});
// 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
// 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.
The callback type generation system automatically handles encrypted computation results, eliminating manual byte parsing and offset tracking. With properly typed structs, you can work directly with structured data and focus on building your applications rather than handling low-level data conversion.These generated types provide type safety and predictable patterns that make working with encrypted computation outputs straightforward and reliable.