Skip to content

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

Description

@Emmyt24

Depends on: #214, #217. Blocks: #221, #224.

Description

This is the deepest conceptual gap in the epic. Octo's deposit model
(docs/deposit-model.md) gives every customer a muxed address: one base
account plus a 64-bit id, so a deposit lands in a single account already tagged with the customer
id — no new on-chain account, no reserve, no sweep.
Store::allocate_address just bumps next_muxed_id in a transaction.

EVM has no such mechanism. Each customer needs a real, distinct, HD-derived EOA, which changes
the economics and the risk profile:

  • Funds arrive at N different addresses and must be swept to a treasury (feat(wallet): EVM deposit sweep engine #224).
  • Sweeping costs gas at the deposit address, which holds no native token — so the sweeper must
    fund it first, or use a smart-contract forwarder.
  • The address's private key must be derivable to sweep — so the server does hold keys that can
    move customer funds, a departure from the non-custodial posture in
    0012_client_custody.sql. AD-4 permits this
    narrowly; it must be documented, bounded, and understood.

Implement EVM deposit-address allocation, preserving the atomicity guarantee the Stellar path has.

Requirements and context

  • Atomicity is non-negotiable. Two concurrent allocations must never receive the same
    derivation index. Reuse the existing transaction + row-lock pattern in allocate_address.
  • Evaluate CREATE2 forwarders as an alternative and record the decision in the PR. Trade-off:
    HD EOAs are simpler and need no deployment, but need pre-funding for gas and hold live keys.
    CREATE2 forwarders can be counterfactual (address known before deployment) and let the sweep be
    pull-based, but cost more gas and add contract risk. Either is acceptable; an undocumented
    choice is not.
  • Derivation index space and the addresses.derivation_index column from feat(store): Multi-chain database schema migration #214 must agree. BIP-44's
    non-hardened index level is bounded at 2^31 - 1.
  • Security — this is the critical one: with non-hardened derivation, xpub + any one child
    private key ⇒ every sibling private key
    . If the extended public key is exposed anywhere (an
    API response, a log, a webhook payload) and a single deposit key ever leaks, every customer
    deposit address on that wallet is compromised. Treat the xpub as a secret and say so in the
    threat model.
  • The same address must be re-derivable deterministically from the seed + index for disaster
    recovery. Store the index, not just the address.

Suggested execution

Branch: feat/evm-deposit-addresses

Implement changes

  • Extend Store::allocate_address (or add a chain-dispatched sibling) to allocate an EVM address:
    bump a per-wallet next_derivation_index under the same row lock, derive via feat(evm-core): secp256k1 keys, BIP-44 derivation, and EIP-55 addresses #217, and insert with
    chain_id.
  • Store the EIP-55 checksummed form for display but index and compare on the lowercase form
    otherwise a client sending a lowercase address will fail to match a checksummed stored row. Add
    a functional index or a normalised column.
  • Implement ChainAdapter::derive_deposit_address for EvmAdapter; Stellar's implementation keeps
    returning the muxed pair unchanged.
  • Update the addresses API in crates/api/src/routes/addresses.rs
    to return chain-appropriate shapes — EVM responses must not carry memo_id, and clients must
    not be encouraged to send a memo (there is nowhere for it to go).
  • Update docs/deposit-model.md with the EVM model, side by side with the
    muxed model, including the economic differences.

Test and commit

  • Concurrency test: N parallel allocations on one wallet yield N distinct indexes and N distinct
    addresses, with no gaps that would break recovery. Model it on the existing
    supervisor_concurrency_tests.rs.
  • Determinism test: the same seed + index always produces the same address across runs and processes.
  • Case-normalisation test: looking up a deposit address by its lowercase, uppercase, and
    checksummed forms all resolve to the same row.
  • Test that a Stellar wallet still allocates muxed addresses with completely unchanged behaviour.
  • Update docs/threat-model.md with the xpub + sibling-key-derivation risk.

Example commit message

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

Stellar muxed accounts have no EVM equivalent, so each customer gets a
real derived EOA at m/44'/60'/0'/0/i, allocated under the same row lock
that guarantees gap-free muxed ids today.

Addresses are stored EIP-55 checksummed for display but indexed
lowercase, so client lookups match regardless of casing.

Documents the non-hardened derivation risk: xpub plus one leaked child
key compromises every sibling.

Refs #220

Guidelines

Security-sensitive. The PR must state plainly which keys the server now holds, what they can move,
and what bounds the exposure.


Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third Campaignarea/backendBackend crates: api, store, ingest, webhooks, bin/serverdifficulty/hardHard, complex, cross-cuttingrisk/security-sensitiveTouches key material, signing, or fund movement — review carefullytype/epicTracking issue for a multi-issue epic

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions