feat(store): EVM per-customer deposit addresses via HD derivation - #229
Open
ALLEN-AYODEJI wants to merge 1 commit into
Open
feat(store): EVM per-customer deposit addresses via HD derivation#229ALLEN-AYODEJI wants to merge 1 commit into
ALLEN-AYODEJI wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Schema (0021_evm_deposit_addresses.sql)
Store
API (crates/api/src/routes/addresses.rs)
addressand 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.Docs
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