ConceptsDerivation

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:

  • owner is the Solana account that SIGNS the request, which soda reads from requester.key(). A wallet signing for itself is the owner of its own address; a program signing for its PDA with invoke_signed makes 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.
  • path is 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_tag selects the foreign chain (ETH_SEPOLIA, ETH_MAINNET, BTC_TESTNET, etc.).
  • group_pk is the committee’s aggregate public key on the secp256k1 curve.
  • G is the secp256k1 generator point.
  • address_encoding is chain-specific: keccak256 last-20-bytes for Ethereum, blake2b256(0x01 || compressed_pk) for Sui (the 0x01 is 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_pk

So 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_pk for 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:

  1. Heap allocation. Box::new the heavy intermediates so the curve operations live on the heap (32 KB available) instead of the stack.
  2. #[inline(never)] everywhere. Spread stack frames across many small functions so no single frame exceeds the budget.
  3. Solana point-add syscall. Lobby for / wait for a native syscall for secp256k1 point addition (analogous to the existing alt-bn128 syscalls).
  4. 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.