soda program
The on-chain primitive. An Anchor 0.32.1 program with four instructions
and two events. Live on Solana devnet at
CPAEfBXpMMsUrjLNhDYxaCH79DYvFHJFC27fttnxAL1J.
Instructions
init_committee
pub fn init_committee(
ctx: Context<InitCommittee>,
group_pk: [u8; 33],
) -> Result<()>One-time setup. Creates the Committee PDA and stores the aggregate group
public key in SEC1 compressed form, with the caller as authority and
signer_count = 1. v0 uses a single dev key; v1 will add threshold + signer
set.
| Account | Type | Notes |
|---|---|---|
committee | PDA, init | Seeds: [b"committee"] |
authority | Signer, mut | Pays rent; recorded as committee.authority |
system_program | Program |
request_signature
pub fn request_signature(
ctx: Context<RequestSignature>,
derivation_seeds: Vec<u8>,
payload: [u8; 32],
chain_tag: [u8; 32],
domain_id: u32,
) -> Result<()>Creates a SigRequest PDA and emits SigRequested. Designed for CPI from
any caller program. There is no foreign_pk_xy parameter: the program
derives foreign_pk = group_pk + tweak·G itself, with the tweak keyed on
the requester (the signer), so a caller cannot request a signature for
an address it does not control. See
Concepts → Derivation.
derivation_seeds is at most 64 bytes. domain_id selects the signature
scheme; only 0 (secp256k1 ECDSA) exists today, and the field is there so
an Ed25519 committee can be added later without a breaking instruction
change. The request expires 300 seconds after creation (expires_at).
| Account | Type | Notes |
|---|---|---|
committee | PDA | Read-only, used for group_pk |
sig_request | PDA, init | Seeds: [b"sig", requester, payload] |
requester | Signer, mut | Pays rent; its key is the derivation owner |
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,
sets completed = true, and emits SigCompleted.
Returns SodaError::AlreadyCompleted (0x1770) if the request is already
finalized. Off-chain components treat this as success.
| Account | Type | Notes |
|---|---|---|
committee | PDA | Seeds: [b"committee"] |
sig_request | PDA, mut | Seeds: [b"sig", sig_request.requester, sig_request.payload] |
submitter | Signer | Anyone |
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 requester: Pubkey,
pub foreign_pk_xy: [u8; 64],
pub payload: [u8; 32],
pub chain_tag: [u8; 32],
pub derivation_seeds: Vec<u8>,
pub domain_id: u32,
}Emitted by request_signature. Off-chain signers listen for this,
re-derive the tweak from (requester, derivation_seeds, chain_tag), and
sign payload with group_sk + tweak.
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 bump: u8,
pub authority: Pubkey,
pub group_pk: [u8; 33],
pub signer_count: u8,
}PDA seeds: [b"committee"]. One per deployment. group_pk sits at byte
offset 41 (after the 8-byte discriminator, bump and authority).
SigRequest
#[account]
pub struct SigRequest {
pub bump: u8,
pub requester: Pubkey,
pub committee: Pubkey,
pub foreign_pk_xy: [u8; 64],
pub derivation_seeds: Vec<u8>, // at most 64 bytes
pub payload: [u8; 32],
pub chain_tag: [u8; 32],
pub domain_id: u32, // 0 = secp256k1 ECDSA
pub expires_at: i64,
pub completed: bool,
pub signature: [u8; 64],
pub recovery_id: u8,
}PDA seeds: [b"sig", requester, payload]. One per (requester, payload)
pair, so the same signer asking for the same bytes twice converges on the
same account.
Errors
| Code | Name | When |
|---|---|---|
0x1770 | AlreadyCompleted | finalize_signature called on a completed request |
0x1771 | PubkeyMismatch | Recovered key did not equal stored foreign_pk_xy |
0x1772 | Expired | Request past expires_at |
0x1773 | InvalidRecoveryId | recovery_id not in {0, 1} |
0x1774 | SeedsTooLong | derivation_seeds over 64 bytes |
0x1775 | InvalidGroupPk | group_pk is not a valid compressed secp256k1 point |
0x1776 | InvalidTweak | Tweak is zero or not below the group order |
0x1777 | DerivationFailed | The recover-based derivation produced no point |
0x1778 | RecoverFailed | secp256k1_recover syscall failed in finalize_signature |
0x1779 | UnsupportedDomain | domain_id other than 0 |
Compute budget
finalize_signature uses around 25,000 CU for secp256k1_recover plus
the usual Anchor account-deserialization overhead. request_signature
spends one more secp256k1_recover on the derivation. Both fit 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 {
committee: ctx.accounts.committee.to_account_info(),
sig_request: ctx.accounts.sig_request.to_account_info(),
requester: ctx.accounts.user.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
);
request_signature(
cpi_ctx,
derivation_seeds, // arbitrary identifying bytes, at most 64
payload,
chain_tag, // [u8; 32]
0, // domain_id: secp256k1 ECDSA
)?;
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, and contracts/programs/sui_demo/src/lib.rs (below) for the Sui
equivalent.
sui_demo caller
The Sui caller of the primitive, declared as
9LBE5dntoLRV61AM3W3ZHikgZPqZ5MLS4xCVvSxxbXug. demo.sh deploys it to
whichever cluster is missing it; as of 2026-09-10 it has run on a local
validator against Sui testnet and its devnet deploy is pending. Same shape
as eth_demo:
the program builds the foreign transaction itself, hashes it the way the
foreign chain will, and commits that hash through soda::request_signature.
soda itself is unchanged: same committee, same SigRequest, same
finalize_signature, same SigCompleted.
Two things differ from the EVM path, and both live here rather than in
soda:
- Sui puts the sender inside the signed bytes, so the program derives
the signer’s Sui address before the CPI (
sodaderives the same key again inside; they agree by construction). - Sui hashes with blake2b, which has no Solana syscall, so a small RFC
7693 implementation ships in this crate (
blake2b.rs). One compression is about 1.2k 64-bit operations and a transfer is two or three blocks, a fraction of onesecp256k1_recover.
sign_sui_transfer
pub fn sign_sui_transfer(
ctx: Context<SignSuiTx>,
recipient: [u8; 32],
amount_mist: u64,
gas_payment: Vec<SuiObjectRef>, // 1..=4
gas_price: u64,
gas_budget: u64,
chain_tag: [u8; 32], // tag32("sui-testnet") | tag32("sui-devnet")
derivation_seeds: Vec<u8>,
) -> Result<()>Send amount_mist of SUI from the signer’s derived Sui address to
recipient. The program builds the whole transaction (the PTB, the sender,
the gas envelope), so the caller supplies only the recipient, the amount and
the gas coins it read from a Sui node. What the wallet shows is what will
happen.
sign_sui_tx
pub fn sign_sui_tx(
ctx: Context<SignSuiTx>,
kind_bytes: Vec<u8>, // BCS TransactionKind, first byte 0x00, at most 768 bytes
gas_payment: Vec<SuiObjectRef>,
gas_price: u64,
gas_budget: u64,
chain_tag: [u8; 32],
derivation_seeds: Vec<u8>,
) -> Result<()>Any Sui transaction. kind_bytes is a BCS TransactionKind holding a
ProgrammableTransaction (for example @mysten/sui’s
Transaction.build({ onlyTransactionKind: true })); the program wraps it in
the same envelope. Move calls, DeFi, NFTs: the primitive does not interpret
the commands. It guarantees only that the signer authorised exactly these
bytes and that the derived address is the sender.
pub struct SuiObjectRef {
pub object_id: [u8; 32],
pub version: u64,
pub digest: [u8; 32], // the bytes behind the base58 string a node prints
}Accounts (both instructions)
| Account | Type | Notes |
|---|---|---|
user | Signer, mut | The derivation owner; pays for the SigRequest |
committee | PDA | soda’s [b"committee"] PDA, seeds::program = soda::ID; read for group_pk. Auto-resolved from the IDL |
sig_request | mut | soda’s [b"sig", user, payload] PDA; created by the CPI |
soda_program | Checked against soda::ID in the handler | |
system_program | Program |
What happens on-chain
tweak = sha256("SODA-v1" || user || derivation_seeds || chain_tag)andforeign_pk_xy = group_pk + tweak·Gviasoda::derive_onchain, the same two callsrequest_signaturemakes.sender = blake2b256(0x01 || compress(foreign_pk_xy)).tx_bytes = BCS(TransactionData::V1 { kind, sender, gas_data { payment, owner: sender, price, budget }, expiration: None }).payload = sha256(blake2b256([0, 0, 0] || tx_bytes)).- CPI
soda::request_signature(derivation_seeds, payload, chain_tag, 0). - Emit
SuiTxRequested.
SuiTxRequested
#[event]
pub struct SuiTxRequested {
pub sig_request: Pubkey,
pub sender: [u8; 32], // the derived Sui address the transaction is from
pub tx_bytes: Vec<u8>, // BCS TransactionData: sign, attach 0x01 || sig || pk, submit
}The relayer’s job is the same as for EthTxRequested: cache tx_bytes by
sig_request, and on SigCompleted attach
encodeSuiSignature(signature, compressPk(foreign_pk)) and call
executeTransaction.
Errors
| Code | Name | When |
|---|---|---|
0x1770 | NotProgrammable | kind_bytes does not start with 0x00 |
0x1771 | KindTooLong | kind_bytes exceeds MAX_KIND_LEN (768) |
0x1772 | BadGasPayment | gas_payment is empty or holds more than MAX_GAS_COINS (4) refs |
Compute
Two on-chain derivations (one here, one inside soda), each a
secp256k1_recover syscall, plus blake2b over a few hundred bytes. A
transfer lands well under the 200 K CU default, but the clients set a 400 K
CU limit with a ComputeBudgetProgram pre-instruction so a longer PTB
through sign_sui_tx never fails on the default budget.
Tests
12 Rust unit tests (cargo test -p sui_demo --lib; 11 hand-written plus
Anchor’s generated test_id): blake2b vectors from
@noble/hashes (empty input, abc, the 127/128/129-byte block boundary,
multi-block inputs), and BCS / hash vectors produced by @mysten/sui 2.30
(transfer kind, full envelope with one and two gas coins, address from a
compressed key, intent digest, signing payload, transaction digest). The
TypeScript twin in packages/soda-sdk/src/sui.ts is tested against the
same vectors.