Deposit and subaccount creation
The shape of the flow is not what it looks like
A deposit is not “the user sends a token to an HD address on Base and the backend moves it to Derive”. It is the other way round.
user's wallet backend
│
├─ (if ETH) deposit() on WETH9 — wrap native ETH
├─ approve(vault, amount) on the SOURCE chain
├─ depositToAppChain( on the SOURCE chain
│ receiver = the user's HD address,
│ amount, msgGasLimit, connector)
│
▼ the bridge mints the wrapper ALREADY on Derive Chain (957)
the user's HD address on Derive Chain
│ DepositWatcher: Transfer logs → deposit_detected
│ SweepQueue: enqueue
▼ GasFunder: put dust on the HD address
platform SCW on Derive Chain Sweeper: ERC20 transfer, signed by the HD key
│
▼ SubaccountProvisioner: private/deposit (EIP-712)
PM2 subaccount at the exchange confirmed through the deposit historyThe backend neither builds nor signs the deposit transaction. It says where to send
(GET /deposit-address) and then watches. There is no cross-chain work on our side at all — the
sweep happens inside Derive Chain.
There is no EIP-3009 (transferWithAuthorization) in this project — a search across the whole
repository returns nothing.
The neighbouring and different mechanism, BridgeInSender (Base → Derive Chain), belongs to the
conversion circle, not to deposit intake. See Conversion and swap.
GET /deposit-address
treasury/treasury.controller.ts:25. Returns the HD address, destinationChainId: 957
(eth_chainId on rpc.lyra.finance = 0x3bd, verified 2026-08-18), msgGasLimit: 200_000, and a
copy of all 11 routes.
The HD address comes from auth/hd-wallet.ts on path m/44'/60'/0'/0/{index} — the same default
ordinary wallets use, so the address can be recovered with a third-party tool from the same mnemonic
if our code is unavailable. See Auth and HD wallets for how the
index is issued.
The route matrix
treasury/deposit-policy.ts, 11 rows, routeFor(asset, chainId).
| asset | chainId | token | decimals |
|---|---|---|---|
| ETH | 1, 10, 8453, 42161 | WETH | 18 |
| BTC | 1, 10, 42161 (no Base) | WBTC | 8 |
| USDC | 1, 10, 8453, 42161 | USDC | 6 |
Addresses are taken from Socket’s deployment file (prod_lyra_addresses.json), not from Derive’s
documentation — which lists the vault’s own address in the “Token Address” column for WBTC on
Ethereum.
Three traps, written into the code:
- The connector comes from the
FASTbranch. On Ethereum every token also has aNATIVE_BRIDGEone — the canonical slow OP Stack path. - The ABI must not be taken from HEAD: that is the next generation of contracts. Derive runs the
previous one — a bytecode scan on 2026-08-19 found
depositToAppChain,getMinFees(address,uint256),token__(),getCurrentLockLimit(address). - A pair existing in the deployment file is not a live route. USDC.e on Optimism and Arbitrum
was excluded exactly this way:
getCurrentLockLimit(connector)returned 0 against 100,000,000 for canonical USDC.
Limits are checked offline, when the row is created, not at runtime on a deposit. At runtime limits are read only on withdrawal and on the bridge-in.
Why native ETH needs WETH
There is no native vault anywhere in the Lyra deployment — the marker address 0xEeee…EEeE does
not appear in prod_lyra_addresses.json, and token__() on all four ETH routes returns canonical
WETH. Without wrapping, an ETH deposit is impossible in principle. The frontend inserts a deposit()
call on WETH9 before the bridge, driven by the wrapsNative flag.
The pair asset: ETH / tokenSymbol: WETH is the only place where it is visible that the bridge
accepts the wrapper. Someone who deposited native ETH gets WETH back on withdrawal, and the modal
says so in advance (W8).
Watching for the arrival
treasury/deposit-watcher.ts. Chain derive, purpose = 'deposit:derive'.
- The filter is
Transfer(address,address,uint256)from any sender, with every HD address as recipients in a single filter: an array in a topic position is an OR, so a tick costs one request per token rather than one per user. - The address → user map is built once per tick; deriving each address is cryptography.
- Tokens come from
treasury/derive-tokens.ts— addresses on Derive Chain, not fromDEPOSIT_ROUTES: USDC0x6879…8481(6), ETH0x15CE…678E(18), BTC0x9b80…18EC(8). Taken frompublic/get_all_currencies→protocol_asset_addresses.underlying_erc20and verified by direct call on 2026-08-18. - Window:
from = cursor+1,to = min(head, from + 5000 - 1)— RPCs refuse wide ranges.
The cursor is its own table, scan_cursors, not max(block). Blocks with no transfers of ours
must also be marked as scanned; otherwise after a quiet day the scanner would restart from the last
deposit and re-read tens of thousands of blocks every time.
The cursor moves after all events in the window are written. Were it the other way round, a failed write would leave the block marked scanned and the deposit lost.
The amount is canonicalised at the chain boundary: formatUnits leaves '1.0', the journal is
append-only, and the amount string goes into the payload forever.
The FX rate comes from SnapshotIndexPriceSource. While the showcase is cold it throws
SnapshotNotReadyError and the tick dies without moving the cursor — which is correct, and better
than recording a deposit at an invented fx_rate in an append-only journal.
deposit_detected → +transit / −external.
Sweep
Why it exists: private/deposit debits the balance of the wallet that owns the subaccounts,
and that is the platform SCW. While the money sits on the HD address the exchange cannot see it.
The queue SweepQueue is built on onchain_txs rather than balance polling. The earlier version
polled balanceOf per user × token and derived a key each time — at a hundred users that is three
hundred RPC calls a minute, of which zero are useful. The queue is durable: a deposit that could
not be swept waits for the next tick. pendingUsers takes DISTINCT user_id, because a sweep moves
the whole balance at once.
The user’s own HD key signs it. Gas is physically paid by the HD address, but the platform puts
the dust there in advance from GAS_WALLET_PRIVATE_KEY — a deliberately separate key from
MASTER_PRIVATE_KEY, which owns the SCW and therefore every user’s funds.
It is a plain ERC20 transfer. There is no ETH/ERC20 branch: “ETH” in DERIVE_TOKENS is the
ERC20 wrapper on Derive Chain. Native ETH on an HD address is never swept — it is gas dust.
Order inside Sweeper.tick: empty queue → exit on one SELECT; balanceOf; gas is checked after
the balance (no point asking for a native balance where there is nothing to move); not enough gas →
a loud warn with address, amount and threshold; receipt.status !== 1 → warn, and no event is
written. markSwept closes the queue entry only if the work finished for every token — that is
exactly how a deposit survives both a gas shortage and a failed transaction.
The gas floor was lowered a thousandfold after measurement: 1e12 wei instead of 1e15. An
ERC20 transfer on Derive Chain costs 66,456 gas at 0.0001–0.001 gwei plus L1 data ≈ 8e-8 ETH; the old
value demanded three dollars sitting on the address in order to spend hundredths of a cent.
deposit_swept produces no postings: the money did not cross the user’s boundary, and transit
already grew on deposit_detected.
Depositing into the subaccount
subaccounts/subaccount-provisioner.ts. Two consumers: the funding step of a position cycle, and
the depositing stage of a conversion circle.
DECIMALS = { USDC: 6, ETH: 18, BTC: 8 } a property of the token, hence hardcoded
registry.spotAsset(currency) BEFORE any write or send
registry.pm2Manager(asset, currency) BEFORE any write or send
repo.claimOrCreate(userId, asset)
deriveSubaccountId === null ? createSubaccount : deposit
→ { state: 'requested', transactionId, subaccountRowId, manager }Both registry reads happen before the write: unacceptable collateral must be rejected before a subaccount row is half-created.
fund writes no journal event. requested means the exchange accepted the signature, not that
money moved. The posting appears in confirm, and only with the amount the exchange confirmed
(P6).
manager is returned outward rather than re-derived at confirmation: minutes pass between send
and confirm, a second registry read could return a different address, and the database would record
a manager the subaccount was not created under.
marginCurrency: request.asset is the account’s base asset, not the currency of the
contribution: a PM2-BTC subaccount is margined in USDC.
CurrencyRegistry.pm2Manager(asset, collateralCurrency) takes two parameters because the
managers list answers “who accepts THIS currency as collateral”. Hence the oddity, fixed by a live
response: XRP’s PM2 manager sits under the USDC entry.
Signing
Two independent signatures; neither substitutes for the other.
| Transport | Action | |
|---|---|---|
| What is signed | a timestamp string | the encoded action |
| Scheme | personal_sign, EIP-191 | EIP-712 |
| Where it goes | X-LyraWallet/Timestamp/Signature headers | the request body |
| Who requires it | nginx in front of the app | the Derive protocol |
Codes: 401 — no headers at all; 403 — present, but the signer is not authorised; 200 with
{"error":{"code":14000}} — authentication accepted, the wallet has no account.
The owner may not be an EOA. Onboarding through Derive’s own interface creates a smart-contract
wallet on top of an EOA. The owner address comes from DERIVE_WALLET_ADDRESS rather than being
derived from the key.
The nonce of deposit operations is 19 digits and typed bigint. Number() would round it, the
signature would then be about a different number, and the exchange answers
Signature invalid for message or transaction — indistinguishable from a key problem. It goes into
the request as a string: JSON.stringify does not serialise a bigint.
trimTrailingZeros in the encoder: numeric(38,18) from Postgres returns
'240.000000000000000000'. This is precisely what stopped the first live cycle on 2026-08-25 —
funding failed on collateral of 240 and drove the subaccount to stopped.
The trading module always works at 18 decimals; the deposit module uses native decimals. A copied
parseUnits(x, 18) would overstate a USDC amount by 10¹².
The margin type is stated twice: as margin_type: 'PM2' in the request (without it, Invalid params, verified 2026-08-21) and as the manager address inside the signature. A disagreement between
the two is the nastiest available outcome.
Creating the subaccount
When: not at registration and not at deposit, but at first funding, lazily. An empty subaccount cannot be created — creation is the first deposit.
One subaccount per (user, asset), UNIQUE(user_id, asset), margin_type = 'PM2'. claimOrCreate
is ON CONFLICT DO NOTHING plus a re-read rather than SELECT+INSERT: two concurrent position
openings would otherwise both see emptiness. 'PM2' is set explicitly even though the column has
the same default — a database default is not where a margin-type decision belongs.
manager_address is stored in the database because the word in margin_type diverged from
reality on a live account on 2026-08-15: Derive’s interface created SM where the specification said
PM2.
Confirmation takes an extra lap
private/create_subaccount returns only a receipt; the subaccount id is not in the response. And
private/get_transaction does not exist — 404, verified live on 2026-08-21. The only way to learn
the fate of an asynchronous operation is the deposit history. For creation that costs an extra lap:
the history cannot be queried without an identifier, and there is no identifier until the exchange
has created the account.
The way around it, in FundingStep:
- Before sending,
getSubaccountIds()is captured intostepState.knownIds— and only when a new one is being created. findCreatedtakes the ids absent fromknownIdsand looks for our receipt in each candidate’s deposit history. Identification is bytransaction_id, not by “a new id appeared” — the latter is wrong if a subaccount for a different asset of the same wallet was created concurrently.
Statuses: SUCCESS = {settled}, FAILURE = {reverted, ignored, timed_out}. An unrecognised
status is deliberately not terminal. status in the schema is z.string(), not z.enum — the
full list has not been confirmed live.
confirm does attachDeriveId first, then the posting. The reverse order would, on a process crash,
leave a recorded movement of money into a subaccount the database does not know about.
The treasury scheduler
@Cron(EVERY_MINUTE, { waitForCompletion: true, unrefTimeout: true }), lock 728_035_957_001.
waitForCompletion keeps a tick longer than a minute from overlapping the next one; cron counts on
wall-clock time and skips a coinciding start entirely. unrefTimeout prevents an open timer from
holding the process alive — without it neither jest nor a migration would ever exit.
ScheduleModule.forRoot() is called exactly once in the whole application, and it is called here.
Nest 11 caches a dynamic module’s token by object reference, and forRoot() returns a new object,
so a second call would create a second ScheduleExplorer — which scans every provider in the
application and would mount every cron twice.
The stage order, and why it is this one:
watcher.tick()— scan for deposits.funder.fund(users)— gas onto HD addresses. Between watching and sweeping: earlier and we would not know whom to fund; later and every user’s first deposit would wait an extra minute.sweeper.tick(). After watching: otherwise the transfer to the SCW would happen before the arrival was recorded, and the journal would contain a sweep with no deposit.funder.fundOperational()— the master EOA and the SCW. Before withdrawals: without gas a request fails and the money stays reserved until the next tick.withdrawer.tick().deliveryWatcher.tick().
Each stage has its own try/catch and returns {count, failed}: with a single number the log could
not tell “did 0 because there was nothing to do” from “crashed”. The summary is printed only if
something happened or there was an error.
Not verified live
createSubaccounthas never been executed (M4a) — only the module’s encoding was checked, throughpublic/deposit_debug. See the contradiction in the code.- The full list of operation statuses is not confirmed by live observation.
- The withdrawal history has no
transaction_id— matching is by asset, amount and time. OPERATIONAL_SCW_TARGET_WEIis temporarily lowered “for the first live run, not as a production figure”; return it to 0.001 ETH once the gas wallet is topped up.