Architecture
SODA splits responsibilities across three layers: an on-chain primitive
(soda Anchor program), an off-chain MPC committee (signing daemons),
and a permissionless relayer that broadcasts to the foreign chain.
End-to-end pipeline
The full request-to-broadcast flow, taking the v0 ETH demo as the example:
In v0 the demo CLI also broadcasts to Sepolia in parallel with the relayer. Both attempts converge on the same transaction hash; whichever lands first wins, the other observes “already known” and exits cleanly.
Components
On-chain (Anchor)
Three programs, all deployed on Solana devnet, and demo.sh deploys any
that are missing on whichever cluster you point it at:
| Program | Program ID | Purpose |
|---|---|---|
soda | 2YDHaX2fPXdmH14hgSJQHMJQpEHrXofzhu5hDVFgFiVd | Core primitive: init_committee, request_signature, finalize_signature |
eth_demo | GfAuUesztZ98BhUZv6ymLxvxty7matXJJ4xRh5zrvPkA | Demo harness: builds RLP, keccaks, CPIs into soda |
sui_demo | 9LBE5dntoLRV61AM3W3ZHikgZPqZ5MLS4xCVvSxxbXug | Sui caller: derives the sender, BCS-encodes, blake2b + sha256, CPIs into soda |
The soda program never imports k256 or any heavy curve crate. It uses only
the Solana runtime’s secp256k1_recover syscall (~25,000 CU) for verification.
That keeps the BPF stack budget intact.
Off-chain components
Signer daemon (contracts/signer/)
A Rust Tokio binary that:
- Subscribes to Solana logs filtered by the
sodaprogram ID. - Decodes
SigRequestedevents fromProgram data: <base64>log lines using borsh + thesha256("event:SigRequested")[..8]discriminator. - Computes
foreign_pk = group_pk + tweak·Gfor each known requester program and matches it against the storedforeign_pk_xyon the event. (See Concepts → Derivation.) - Signs the payload with
(sk + tweak) mod nviak256::ecdsa::SigningKey::sign_prehash_recoverable. - Submits a
finalize_signatureinstruction (manually built — noanchor-clientdep).
Run with pnpm signer.
Relayer (apps/relayer/)
A standalone Node service that:
onLogs-subscribes to thesoda,eth_demoandsui_demoprogram IDs.- Manually decodes
EthTxRequested,SuiTxRequestedandSigCompletedborsh events. - Caches
(sig_request, chain_id, unsigned_rlp)fromEthTxRequestedand(sig_request, sender, tx_bytes)fromSuiTxRequested. - On
SigCompleted, the cache says which chain the signature is for: re-encode the RLP with(v, r, s)and POSTeth_sendRawTransaction, or wrap0x01 || r || s || pkandexecuteTransactionover Sui GraphQL.
Run with pnpm relayer:dev.
TypeScript SDK (packages/soda-sdk/)
Source-only ESM workspace package. Exports derivation, RLP encode / decode,
EIP-155 v calculation, an EthRpc client, and for Sui the BCS encoders,
the two-stage hash and a SuiGraphQl client. Consumed by the demo CLI,
the web app, and the relayer via workspace:*.
Second chain family: Sui
Sui is the first non-EVM chain behind the primitive. It accepts secp256k1
signatures natively (scheme flag 0x01), so the committee’s ordinary ECDSA
output is a valid Sui user signature once wrapped correctly.
What changed:
| Layer | Sui-specific piece |
|---|---|
| Caller program | programs/sui_demo: sign_sui_transfer / sign_sui_tx. Derives the sender on-chain (Sui signs over it), BCS-encodes TransactionData, computes sha256(blake2b256(intent || tx)), CPIs request_signature, emits SuiTxRequested { sig_request, sender, tx_bytes }. Ships its own blake2b because Solana has no syscall for it. |
| Encoder | packages/soda-sdk/src/sui.ts: address blake2b256(0x01 || pk), BCS TransactionData::V1, the payload / digest hashes, the 0x01 || r || s || pk envelope, and a GraphQL client (public JSON-RPC is deprecated on Sui). Held byte-for-byte against @mysten/sui in tests. |
| Assembly / broadcast | SuiTxRequested.tx_bytes + SigCompleted.signature → encodeSuiSignature → executeTransaction. apps/demo/src/demo-sui.ts does it inline; apps/relayer does it from events: it caches SuiTxRequested by sig_request, and on SigCompleted recovers the pubkey from the signature (the event does not carry it), checks blake2b256(0x01 || pk) against the cached sender, then submits over GraphQL (SUI_CHAIN picks the network). Idempotent with the CLI: a resubmission of the same bytes is logged as already submitted. The web app’s /sui page and /api/sui/finalize do the same server-side after Phantom signs. |
| Gas | The sender’s own SUI coin objects (id, version, digest) are the gas payment and replace the nonce; the client reads them before building. |
| Audit | apps/demo/src/verify-sui.ts (pnpm verify:sui <digest>). |
What did not change: soda (no new instruction, no new field), the
committee and its signing, finalize_signature and its secp256k1_recover
check, SigCompleted, and the derivation formula. Only the chain tag
differs, so one owner has an unrelated address on Sui and on Base. See
Sign a Sui tx and the
sui_demo reference.
v0.5: real Lindell ‘17 2-of-2 MPC ECDSA — live on AWS
As of 2026-05-11 the demo runs against a real threshold ECDSA committee deployed across three EC2 instances in us-east-1:
| Role | Public IP | Holds |
|---|---|---|
mpc-node-p1 | 44.201.168.181 | share x1 |
mpc-node-p2 | 54.88.35.104 | share x2 |
mpc-coordinator | 32.198.7.34 (health) | nothing — only forwards bytes |
The on-chain Committee.group_pk was migrated from the v0 single key to
the AWS joint key via the new
update_committee ix.
./demo.sh now drives signing through the AWS coordinator without any
flag changes. Neither node sees the joint secret. Signing runs the
4-message Lindell ‘17 protocol; on-chain
secp256k1_recover verifies the result unchanged.
caller program → request_signature → SigRequested
│
▼
apps/mpc-subscriber (Solana)
│ POST /sign
▼
apps/mpc-coordinator (drives 4-msg protocol)
/ \
▼ ▼
mpc-node-p1 mpc-node-p2
(holds x1) (holds x2)
r,s,v ← combined signature
│
▼
soda::finalize_signature (secp256k1_recover, no change)Run with pnpm mpc:dkg (once) → pnpm mpc:up → pnpm mpc:update-committee
(swap on-chain group_pk) → pnpm mpc:subscribe. See
Deploy → AWS MPC for the production-shape walkthrough.
Trust model: v0 vs v0.5 vs v1
| Layer | v0 | v0.5 (today) | v1 (target) |
|---|---|---|---|
| Derivation | Off-chain by caller, on-chain bytes-compare | Same | On-chain group_pk + tweak·G (heap-allocated / syscall / zk) |
| Signing key | Single dev k256 on disk | Lindell ‘17 2-of-2, neither node sees group_sk | 2-of-3+ via GG18 / GG20 / CGG21 |
| Fault tolerance | 0 (one box) | 0 (both must be up) | Tolerates n - t losses |
| Committee membership | Hardcoded singleton | Hardcoded pair | Restaked SOL via Solayer / Jito, slashable |
| Replay protection | PDA seed uniqueness | Same | PDA + nonce + expiry |
See Concepts → Committee for the long-form v1 design.