Skip to content

feat(store): EVM per-customer deposit addresses via HD derivation - #229

Open
ALLEN-AYODEJI wants to merge 1 commit into
Octo-Protocol-org:mainfrom
ALLEN-AYODEJI:feat/evm-deposit-addresses
Open

feat(store): EVM per-customer deposit addresses via HD derivation#229
ALLEN-AYODEJI wants to merge 1 commit into
Octo-Protocol-org:mainfrom
ALLEN-AYODEJI:feat/evm-deposit-addresses

Conversation

@ALLEN-AYODEJI

@ALLEN-AYODEJI ALLEN-AYODEJI commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Stellar's muxed-account model gives every customer a deposit address for free: base account + 64-bit id, no new on-chain account, no reserve, no sweep (docs/deposit-model.md). EVM has no equivalent — every customer needs a real, distinct, HD-derived EOA — so this implements that path end to end, including the two prerequisite pieces (a ChainAdapter trait and the schema support for a second address kind) that didn't exist in this codebase yet, scoped down to exactly what allocation needs rather than the full multi-chain epic.

New crates

  • octo-evm-core: BIP-32/BIP-44 secp256k1 derivation restricted to m/44'/60'/0'/{branch}/{index}, EIP-55 checksum encode/validate, and seal/open helpers mirroring wallet-core's sealed-seed pattern (same octo-crypto AES-256-GCM sealing, chain-scoped AAD context so an EVM seed sealed for one chain can't be opened under another). No signing — derivation only; sweeping is separate, later work. Verified against cited sources, not memory: BIP-32 Test Vector 1 (decoded locally from the spec's own xprv strings), all four EIP-55 spec test vectors plus negative (tampered-checksum) cases, and mnemonic->address cross-checks against eth-account (the Ethereum Foundation's reference implementation) for indices 0/1/2/9 and the identity branch.
  • octo-chain: a minimal ChainAdapter trait (derive_deposit_address only) with StellarAdapter (forwards to wallet-core unchanged) and EvmAdapter. Deliberately not the full capability/registry abstraction described for the chain-abstraction epic issue — that trait needs answers (RPC wiring, capability flags for chains that don't exist here yet) that belong to that issue, not this one.

Schema (0021_evm_deposit_addresses.sql)

  • wallets: chain_kind ('stellar'|'evm'), chain_id (CAIP-2), and next_derivation_index, the EVM analogue of next_muxed_id. An EVM wallet is constrained to custody = 'server' (it must carry the sealed seed to derive from) via wallets_evm_is_server_custody, which combined with the pre-existing wallets_server_custody_has_seed check guarantees every EVM wallet has one.
  • addresses: muxed_id/muxed_address relaxed to nullable; derivation_index (bounded 0..=2^31-1, BIP-32's non-hardened ceiling) and evm_address added. A row is constrained to be fully Stellar-shaped or fully EVM-shaped, never a mix. evm_address_lower is a generated column (lower(evm_address)) carrying the uniqueness constraint and the lookup index, so a client sending any casing resolves to the same row.

Store

  • Store::allocate_evm_address mirrors allocate_address exactly: same transaction + row-lock pattern, bumping next_derivation_index instead of next_muxed_id, so two concurrent callers can never collide. Proven with a 25-way concurrent-allocation test asserting the resulting indexes are exactly 0..24 with no gaps or duplicates.
  • Store::create_evm_wallet / NewEvmWallet, Store::address_by_evm_address (case-insensitive lookup via evm_address_lower).
  • Store deliberately still doesn't depend on wallet-core or evm-core (allocation takes a caller-provided derive closure, same as the existing Stellar path) — chain-specific logic stays out of the persistence layer.

API (crates/api/src/routes/addresses.rs)

  • Response shape now depends on chain_kind: Stellar keeps its unchanged muxed_address/base_address/memo_id shape; EVM responses carry address and omit memo_id entirely (not null) — there's nowhere for a memo to go on EVM, so the field doesn't exist to invite sending one. docs/openapi.yaml and docs/api.md updated to match.
  • create_address opens the wallet's sealed seed only for the duration of the derive call (mirrors the existing decrypt-derive-sign-drop pattern in routes/sponsor.rs), via octo_chain::EvmAdapter.

Docs

  • docs/deposit-model.md: the EVM model side by side with the muxed one, the economic differences (N on-chain identities vs. one, sweep + gas funding needed, live server keys), the CREATE2-forwarder alternative and why HD EOAs were chosen instead (recorded per issue requirement, not left implicit), and the recoverability/case-handling rules.
  • docs/threat-model.md: a new AD-4 custody exception for EVM deposit wallets (server holds derivation-capable key material, unlike Stellar user wallets), and the load-bearing security fact: non-hardened derivation means an exposed xpub for m/44'/60'/0'/0 plus any one leaked child key reconstructs every sibling deposit key on that wallet. octo never constructs or exposes an xpub anywhere for exactly this reason.

Tests

  • octo-evm-core (24 tests): BIP-32/EIP-55 vectors above, determinism, distinct-index/branch divergence, index-range rejection, seal/open round-trip and cross-context rejection.
  • octo-store: EVM concurrency (no gaps/dupes), case-insensitive lookup (lower/upper/original all resolve to one row), and an explicit regression test that Stellar allocation is byte-for-byte unaffected. drift_tests.rs's pinned migration-version-set test updated 20 -> 21 (the one pre-authorized test change, per the existing precedent for this exact assertion).
  • Full workspace: cargo build/test/clippy(-D warnings)/fmt all clean; cargo test --workspace run against a real Postgres instance, twice, including the new concurrency and lookup tests.

Refs #220. Depends conceptually on #214 (multi-chain schema) and #217 (evm-core derivation) per the issue, but scoped-down versions of both are included here rather than blocking on those issues landing first.

Closes #220

Stellar's muxed-account model gives every customer a deposit address for
free: base account + 64-bit id, no new on-chain account, no reserve, no
sweep (docs/deposit-model.md). EVM has no equivalent — every customer
needs a real, distinct, HD-derived EOA — so this implements that path
end to end, including the two prerequisite pieces (a ChainAdapter trait
and the schema support for a second address kind) that didn't exist in
this codebase yet, scoped down to exactly what allocation needs rather
than the full multi-chain epic.

New crates
- octo-evm-core: BIP-32/BIP-44 secp256k1 derivation restricted to
  m/44'/60'/0'/{branch}/{index}, EIP-55 checksum encode/validate, and
  seal/open helpers mirroring wallet-core's sealed-seed pattern (same
  octo-crypto AES-256-GCM sealing, chain-scoped AAD context so an EVM
  seed sealed for one chain can't be opened under another). No signing —
  derivation only; sweeping is separate, later work.
  Verified against cited sources, not memory: BIP-32 Test Vector 1
  (decoded locally from the spec's own xprv strings), all four EIP-55
  spec test vectors plus negative (tampered-checksum) cases, and
  mnemonic->address cross-checks against eth-account (the Ethereum
  Foundation's reference implementation) for indices 0/1/2/9 and the
  identity branch.
- octo-chain: a minimal ChainAdapter trait (derive_deposit_address only)
  with StellarAdapter (forwards to wallet-core unchanged) and EvmAdapter.
  Deliberately not the full capability/registry abstraction described
  for the chain-abstraction epic issue — that trait needs answers (RPC
  wiring, capability flags for chains that don't exist here yet) that
  belong to that issue, not this one.

Schema (0021_evm_deposit_addresses.sql)
- wallets: chain_kind ('stellar'|'evm'), chain_id (CAIP-2), and
  next_derivation_index, the EVM analogue of next_muxed_id. An EVM
  wallet is constrained to custody = 'server' (it must carry the sealed
  seed to derive from) via wallets_evm_is_server_custody, which combined
  with the pre-existing wallets_server_custody_has_seed check guarantees
  every EVM wallet has one.
- addresses: muxed_id/muxed_address relaxed to nullable; derivation_index
  (bounded 0..=2^31-1, BIP-32's non-hardened ceiling) and evm_address
  added. A row is constrained to be fully Stellar-shaped or fully
  EVM-shaped, never a mix. evm_address_lower is a generated column
  (lower(evm_address)) carrying the uniqueness constraint and the lookup
  index, so a client sending any casing resolves to the same row.

Store
- Store::allocate_evm_address mirrors allocate_address exactly: same
  transaction + row-lock pattern, bumping next_derivation_index instead
  of next_muxed_id, so two concurrent callers can never collide. Proven
  with a 25-way concurrent-allocation test asserting the resulting
  indexes are exactly 0..24 with no gaps or duplicates.
- Store::create_evm_wallet / NewEvmWallet, Store::address_by_evm_address
  (case-insensitive lookup via evm_address_lower).
- Store deliberately still doesn't depend on wallet-core or evm-core
  (allocation takes a caller-provided derive closure, same as the
  existing Stellar path) — chain-specific logic stays out of the
  persistence layer.

API (crates/api/src/routes/addresses.rs)
- Response shape now depends on chain_kind: Stellar keeps its unchanged
  muxed_address/base_address/memo_id shape; EVM responses carry `address`
  and omit memo_id entirely (not null) — there's nowhere for a memo to
  go on EVM, so the field doesn't exist to invite sending one.
  docs/openapi.yaml and docs/api.md updated to match.
- create_address opens the wallet's sealed seed only for the duration of
  the derive call (mirrors the existing decrypt-derive-sign-drop pattern
  in routes/sponsor.rs), via octo_chain::EvmAdapter.

Docs
- docs/deposit-model.md: the EVM model side by side with the muxed one,
  the economic differences (N on-chain identities vs. one, sweep + gas
  funding needed, live server keys), the CREATE2-forwarder alternative
  and why HD EOAs were chosen instead (recorded per issue requirement,
  not left implicit), and the recoverability/case-handling rules.
- docs/threat-model.md: a new AD-4 custody exception for EVM deposit
  wallets (server holds derivation-capable key material, unlike Stellar
  user wallets), and the load-bearing security fact: non-hardened
  derivation means an exposed xpub for m/44'/60'/0'/0 plus any one
  leaked child key reconstructs every sibling deposit key on that
  wallet. octo never constructs or exposes an xpub anywhere for exactly
  this reason.

Tests
- octo-evm-core (24 tests): BIP-32/EIP-55 vectors above, determinism,
  distinct-index/branch divergence, index-range rejection, seal/open
  round-trip and cross-context rejection.
- octo-store: EVM concurrency (no gaps/dupes), case-insensitive lookup
  (lower/upper/original all resolve to one row), and an explicit
  regression test that Stellar allocation is byte-for-byte unaffected.
  drift_tests.rs's pinned migration-version-set test updated 20 -> 21
  (the one pre-authorized test change, per the existing precedent for
  this exact assertion).
- Full workspace: cargo build/test/clippy(-D warnings)/fmt all clean;
  cargo test --workspace run against a real Postgres instance, twice,
  including the new concurrency and lookup tests.

Refs Octo-Protocol-org#220. Depends conceptually on Octo-Protocol-org#214 (multi-chain schema) and Octo-Protocol-org#217
(evm-core derivation) per the issue, but scoped-down versions of both
are included here rather than blocking on those issues landing first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Hk5NpSPpSpbVbAe9X5NNy
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(store): EVM per-customer deposit addresses via HD derivation

1 participant