Derivation
Every Solana PDA controls a deterministic foreign-chain address. There is no
mapping table, no registration step, and no shared state between the caller
and soda. The address is computed from the caller’s identity alone.
The formula
tweak = sha256("SODA-v1" || owner || path || chain_tag)
foreign_pk = group_pk + tweak · G
foreign_addr = address_encoding(foreign_pk, chain_tag)Where:
owneris the Solana account that SIGNS the request, whichsodareads fromrequester.key(). A wallet signing for itself is the owner of its own address; a program signing for its PDA withinvoke_signedmakes that PDA the owner. It is not the calling program’s id: an earlier design keyed the tweak that way, and every caller then landed on the same address.pathis arbitrary per-request bytes, so one owner can hold several addresses (a vault id, a strategy index, a user pubkey). Empty is the owner’s default account.chain_tagselects the foreign chain (ETH_SEPOLIA,ETH_MAINNET,BTC_TESTNET, etc.).group_pkis the committee’s aggregate public key on the secp256k1 curve.Gis the secp256k1 generator point.address_encodingis chain-specific: keccak256 last-20-bytes for Ethereum,blake2b256(0x01 || compressed_pk)for Sui (the0x01is Sui’s secp256k1 scheme flag, so the address commits to the signature scheme), HASH160 + bech32 for Bitcoin P2WPKH, and so on.
Because chain_tag is a tweak input, the same owner and seeds give a
different foreign_pk, and therefore an unrelated address, on every chain:
tag32("base-sepolia") and tag32("sui-testnet") do not share a key even
though both chains verify secp256k1. This is deliberate (NEAR’s “distinct
derivation path per chain” advice): a signature produced for one chain can
never be replayed as a signature for the same owner on another, and each
chain’s address can be funded and audited on its own.
Why this works
The same tweak applied to the secret key as to the public key yields a key
pair that signs validly under the derived foreign_pk:
sk' = (group_sk + tweak) mod n
pk' = sk' · G
= (group_sk + tweak) · G
= group_sk · G + tweak · G
= group_pk + tweak · G
= foreign_pkSo a signature produced with sk' recovers to foreign_pk under the foreign
chain’s standard signature verification. From Bitcoin or Ethereum’s point of
view, a normal ECDSA signature signed a normal transaction. The chain has no
idea Solana exists.
A program can be the owner
Because the tweak is keyed on whoever signed, and a PDA signing through
invoke_signed is a signer, a Solana program owns a foreign address by
the same formula a wallet does. Nothing in soda is special-cased for it.
let vault_seeds: &[&[u8]] = &[b"vault", authority.as_ref(), &id, &[bump]];
let cpi = CpiContext::new_with_signer(
soda_program, RequestSignature {
requester: vault.to_account_info(), // the PDA owns the address
payer: authority.to_account_info(), // a wallet pays the rent
..
}, &[vault_seeds]);
soda::cpi::request_signature(cpi, path, payload, chain_tag, 0)?;requester and payer are separate accounts precisely so this works: a
program-owned address would otherwise have to hold lamports before it could
ask for its first signature.
The consequence is the point of the whole system. The address moves only
when the program’s own rules allow it, and those rules are enforced by
Solana rather than promised by whoever holds a key.
contracts/programs/vault_demo/ is a worked example: a vault that records
one allowed recipient and refuses to sign a payment to anyone else.
Domain separation
The leading "SODA-v1" constant prevents tweak collisions across:
- Different versions of the protocol (v1 vs a future v2 schema).
- Other applications using the same
group_pkfor unrelated derivations.
Changing the domain string is a hard fork: every derived address changes.
v0 caveat: derivation is computed off-chain
In v0, the caller computes foreign_pk_xy and passes it into
soda::request_signature. The soda program does not verify that the
caller derived foreign_pk correctly from the formula above. It only uses
the value as the expected secp256k1_recover output during finalization.
This means a malicious caller in v0 could pass an arbitrary foreign_pk_xy
and request a signature for it. Because the committee is also a single dev
signer in v0, this does not increase real attack surface beyond “trust the
committee.” But it is not the production design.
v1 path to on-chain derivation
The first attempt did group_pk + tweak·G on-chain via k256::ProjectivePoint
ops. That blew the BPF 4 KB stack. Three viable paths to re-enable:
- Heap allocation.
Box::newthe heavy intermediates so the curve operations live on the heap (32 KB available) instead of the stack. #[inline(never)]everywhere. Spread stack frames across many small functions so no single frame exceeds the budget.- Solana point-add syscall. Lobby for / wait for a native syscall for secp256k1 point addition (analogous to the existing alt-bn128 syscalls).
- zk verification. Verify the derivation off-chain inside a SNARK and submit the proof. Alt-bn128 syscalls are already available on Solana.
See Architecture → Trust model for where this fits in the v0 → v1 progression.
Parity testing
The derivation algorithm is implemented twice for cross-language testing:
- TypeScript:
packages/soda-sdk/src/derive.ts(production path; used by the demo, web app, and relayer). - Rust:
contracts/programs/soda/src/derivation.rs(#[cfg(test)]only; kept for parity tests against the TS implementation).
Run pnpm sdk:test (TS, 42 vitest cases, of which 23 hold the Sui encoders
byte-for-byte against @mysten/sui) and cargo test --workspace --lib
(Rust) to verify both implementations agree on the canonical G + G = 2G
vector, known-answer ETH address derivations, and the Sui address / payload
vectors shared with programs/sui_demo.