soda program
The on-chain primitive. An Anchor 0.32.1 program with three instructions
and two events. Live on Solana devnet at
99apYWpnoMWwA2iXyJZcTMoTEag6tdFasjujdhdeG8b4.
Instructions
init_committee
pub fn init_committee(
ctx: Context<InitCommittee>,
group_pk_xy: [u8; 64],
) -> Result<()>One-time setup. Creates the Committee PDA and stores the aggregate group
public key as uncompressed X || Y. v0 uses a single dev key; v1 will add
threshold + signer set.
| Account | Type | Notes |
|---|---|---|
committee | PDA, init | Seeds: [b"committee"] |
payer | Signer, mut | Pays rent |
system_program | Program |
request_signature
pub fn request_signature(
ctx: Context<RequestSignature>,
foreign_pk_xy: [u8; 64],
seeds: Vec<u8>,
payload: [u8; 32],
chain_tag: u8,
) -> Result<()>Creates a SigRequest PDA and emits SigRequested. Designed for CPI from
any caller program. The caller is responsible for computing
foreign_pk_xy from (group_pk, program_id, seeds, chain_tag). See
Concepts → Derivation.
| Account | Type | Notes |
|---|---|---|
sig_request | PDA, init | Seeds: [b"sig", caller_program_id, seeds_hash] |
committee | PDA | Read-only, used for group_pk_xy |
payer | Signer, mut | |
system_program | Program |
finalize_signature
pub fn finalize_signature(
ctx: Context<FinalizeSignature>,
signature: [u8; 64],
recovery_id: u8,
) -> Result<()>Permissionless: anyone holding a valid signature can finalize the request.
Runs secp256k1_recover(payload, recovery_id, signature) and compares the
result to sig_request.foreign_pk_xy. On match, writes the signature,
flips status to Completed, and emits SigCompleted.
Returns SodaError::AlreadyCompleted (0x1770) if the request is already
finalized. Off-chain components treat this as success.
| Account | Type | Notes |
|---|---|---|
sig_request | PDA, mut | |
payer | Signer, mut |
update_committee
pub fn update_committee(
ctx: Context<UpdateCommittee>,
new_group_pk: [u8; 33],
new_signer_count: u8,
) -> Result<()>Authority-gated swap of the committee’s group_pk (and signer_count).
Used to migrate from the v0 single-key signer to the v0.5 MPC committee’s
joint public key without redeploying the program.
The ix is locked to committee.authority (set at init_committee time)
via Anchor’s has_one = authority, so no one but the original initializer
can change the group key. The on-chain layout doesn’t change — group_pk
stays compressed (33 bytes), signer_count becomes whatever you pass
(e.g. 2 for 2-of-2 Lindell ‘17, 3 for 2-of-3 GG18).
| Account | Type | Notes |
|---|---|---|
committee | PDA, mut | Seeds: [b"committee"], has_one = authority |
authority | Signer | Must match committee.authority |
Used by pnpm mpc:update-committee after a fresh DKG ceremony — see
Concepts → Committee.
Events
SigRequested
#[event]
pub struct SigRequested {
pub sig_request: Pubkey,
pub foreign_pk_xy: [u8; 64],
pub payload: [u8; 32],
pub chain_tag: u8,
}Emitted by request_signature. Off-chain signers listen for this and
decide whether to sign based on whether they can derive the correct tweak
for foreign_pk_xy.
SigCompleted
#[event]
pub struct SigCompleted {
pub sig_request: Pubkey,
pub signature: [u8; 64],
pub recovery_id: u8,
}Emitted by finalize_signature on a successful recover-and-match.
Relayers listen for this and broadcast the assembled signed transaction
to the foreign chain.
Account schemas
Committee
#[account]
pub struct Committee {
pub group_pk_xy: [u8; 64],
}PDA seeds: [b"committee"]. One per deployment.
SigRequest
#[account]
pub struct SigRequest {
pub foreign_pk_xy: [u8; 64],
pub payload: [u8; 32],
pub seeds: Vec<u8>,
pub chain_tag: u8,
pub signature: [u8; 64],
pub recovery_id: u8,
pub status: SigStatus,
}
pub enum SigStatus {
Pending,
Completed,
}PDA seeds: [b"sig", caller_program_id, hash(seeds)]. One per
(caller, seeds) pair.
Errors
| Code | Name | When |
|---|---|---|
0x1770 | AlreadyCompleted | finalize_signature called on a Completed request |
0x1771 | SignatureMismatch | Recovered key did not equal stored foreign_pk_xy |
0x1772 | InvalidRecoveryId | recovery_id not in {0, 1} |
0x1773 | InvalidChainTag | chain_tag not recognized |
Compute budget
finalize_signature uses around 25,000 CU for secp256k1_recover plus
the usual Anchor account-deserialization overhead. The whole instruction
fits comfortably in the default 200 K CU budget.
CPI usage from a caller program
use anchor_lang::prelude::*;
use soda::cpi::accounts::RequestSignature;
use soda::cpi::request_signature;
pub fn my_signing_call(ctx: Context<MySigningCall>) -> Result<()> {
let payload = keccak::hash(&unsigned_rlp).0;
let cpi_ctx = CpiContext::new(
ctx.accounts.soda_program.to_account_info(),
RequestSignature {
sig_request: ctx.accounts.sig_request.to_account_info(),
committee: ctx.accounts.committee.to_account_info(),
payer: ctx.accounts.payer.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
);
request_signature(
cpi_ctx,
foreign_pk_xy, // computed off-chain or in the caller
seeds, // arbitrary identifying bytes
payload,
chain_tag,
)?;
Ok(())
}See contracts/programs/eth_demo/src/lib.rs for a complete working
example that builds an Ethereum legacy RLP transaction and CPIs into
soda.