id: DEFI-2911 title: Separate funding and trading accounts tags: [accounts, permissions, security, api]
Separate funding and trading accounts
Motivation
Programmatic traders (feedback from a market maker integrating with the DEX) must keep a private key available at runtime to sign orders. Today the same principal both holds funds (deposit / withdraw) and trades, so a leak of that hot key exposes the entire balance sitting on the account — a growing concern as balances scale to hundreds of thousands and beyond.
The ask is a two-role model, explicitly compared to Hyperliquid’s API/agent wallets:
- Funding account — an ordinary account: deposits, withdraws, holds balances, and may also trade.
- Trading account — a principal whitelisted by a funding account. It can only place and cancel orders acting on the funding account’s balance; it cannot deposit or withdraw, and it never holds funds itself.
This isolates risk: a compromised trading key exposes only open trading positions (it can place bad orders against the funding account’s balance until revoked), never the ability to move funds out. Every major venue offers this separation — CEXes via permission-scoped API keys, DEXes (Hyperliquid, dYdX v4) via structurally restricted delegate keys (see Cross-exchange comparison).
Requirements
- R1 — Grant. A funding account
Fcan whitelist a principalTviaadd_trading_account. From then onTmay place and cancel orders acting onF’s account.Fcan revoke viaremove_trading_account. - R2 — Orders act on the funding account. An order placed by
Tis economicallyF’s order: validated against and reserved fromF’s free balance, recorded withowner = F, visible inF’sget_my_orders, and its fills settle intoF’s balances. For matching and settlement it behaves exactly as ifFhad placed it (order events carryFas the owner, so event-log replay is unaffected); the acting key stays visible for audit (R13). - R3 — Funding operations denied.
depositandwithdrawcalled by a currently whitelistedTfail synchronously with a dedicated error, before any ledger interaction. Combined with the grant preconditions (R7), a trading account can never hold DEX balances. - R4 — Cancel authority.
Fand any ofF’s trading accounts may cancelF’s open orders. Any other principal — including a trading account of a different funding account — getsNotOrderOwner, exactly as today. Cancel authority is deliberately shared acrossF’s keys regardless of which key placed the order — matching every surveyed venue (keys over one balance share order authority; see the comparison) and keeping key rotation safe: a revoked key’s open orders stay cancellable by the remaining keys. Interference between sibling keys is visible through attribution (R13). - R5 — Reads resolve.
get_balances,get_my_orders, andget_my_tradescalled byTreturnF’s data, exactly as ifFhad called. - R6 — Revocation is immediate. After
remove_trading_account(T), calls byTare treated as coming from an ordinary unknown principal.F’s open orders — including thoseTplaced — are unaffected and stay open. - R7 — Grant preconditions.
add_trading_account(T)fails if any of:Fis not a registered user (has never deposited) — granting requires an existing, economically established account and never creates one;T == F(self-grant);Tis already a trading account, ofFor of anyone else — a trading account maps to exactly one funding account;Tis already a registered user (has ever deposited) — a trading key must be a fresh principal;Fis itself a trading account (no delegation chains; subsumed by the registration requirement once R3 is enforced, but kept explicit because the whitelist PR ships before the funding-denial PR);Falready hasMAX_TRADING_ACCOUNTS_PER_USERtrading accounts;Thas an in-flight deposit or withdraw (a retryable rejection, the envelope’sTemporaryErrorclass). This closes a race on deposit’s await window: without it, “Tis unregistered” could pass whileT’s firsticrc2_transfer_fromawaits the ledger, and the completing deposit would then register and creditTafter the grant made it a delegate — a delegate with a stranded balance of its own. Grant itself is synchronous (no await), so checking the in-flight set (theUserOpGuardstate) at validation closes the window from both sides: a grant during the deposit is rejected, and a deposit after the grant is denied (R3).remove_trading_account(T)fails only ifTis not currentlyF’s trading account.
- R8 — Funding account unaffected.
Fretains full authority — deposit, withdraw, trade, cancel, queries — regardless of how many trading accounts it has whitelisted. - R9 — Introspection.
get_my_trading_accountsreturns the caller’s current whitelist (empty for a principal with none). The whitelist-management surface (add/remove/get) never resolves delegation — it always acts on the raw caller as a funding account. - R10 — Persistence & audit. The whitelist survives upgrade. Grants and revocations are
recorded as events, visible via
get_events, and replay-safe (no double-apply when the event log is replayed with stable-memory writes skipped). - R11 — Error envelope. The new endpoints are non-trapping and return the DEFI-2801 error
envelope (
docs/src/development/specs/DEFI-2801-error-envelope.md); the deposit / withdraw denial is a new variant of the existing error envelopes. - R12 — Restricted-mode interplay. Under
Mode::RestrictedTo, the raw caller must be allowlisted; delegation does not bypass the mode check. A trading account needs its own entry in the restricted-mode allowlist to call anything. - R13 — Attribution. Every caller-initiated order action records the acting key:
OrderRecordand the order-placement event gainplaced_by(optional principal; absent = placed by the funding account itself), and the cancel event recordscanceled_bylikewise.placed_byis exposed throughget_my_orders, so after a key compromiseFcan list — and cancel — exactly the orders that key placed; the events preserve the same trail durably. Attribution is forensic only: it grants no authority and does not restrict cancel (R4). - R14 — Grant rate bound. Successful grants are rate-limited per funding account:
add_trading_accountfails with a retryable error (the envelope’sTemporaryErrorclass) if less thanTRADING_ACCOUNT_GRANT_COOLDOWNhas elapsed sinceF’s previous successful grant.remove_trading_accountis never rate-limited — revocation is the emergency response to a compromised key — and is inherently bounded by prior grants. Failed validations (including cooldown rejections) record no event, so only rate-bounded successful mutations grow the event log.
Non-goals
- Expiry / TTL on grants. Hyperliquid caps agent validity at 180 days; we ship revocation-only and note expiry as a follow-up if operational experience warrants it.
- Scoped grants — per-pair restrictions, notional caps (e.g. “T may trade only these pairs,
at most X per 24 h”), expiry, read-only keys — the dYdX authenticator-filter and MetaMask
delegation-caveat analogues, and the natural shape for an AI-managed (agentic) trading key.
Out of scope now, but the design leaves a deliberate evolution path: the registry value grows
from the funding principal into a grant/policy object (additive CBOR fields, decoding absent
as “unrestricted”),
add_trading_accountgains an optional policy argument (additive Candid), and enforcement slots into the existing admission point (permit_trading/validate_limit_order). Volume caps additionally need per-key consumption accounting, which is why order attribution (R13) ships now: a rolling per-key window hasplaced_byto hang off when caps arrive, instead of a migration. - Many funding accounts per trading key. The mapping is 1:1 by design (see Design Decisions). Principals are free — a service trading for several funders creates one key per funder.
- Consent by the trading principal (two-step grant).
add_trading_accountis unilateral, like Hyperliquid’sapproveAgent. Accepted residual:Fcan claim any unregistered principal as its trading key. The consequence falls onF(the claimed key gains trading power overF’s own funds); a principal claimed against its will simply cannot deposit while whitelisted and its owner would use a different principal. R7’s registered-user check ensures no principal with existing funds or history can ever be claimed, and claiming is not free — the claimer must itself be a deposited funding account, one grant per cooldown (R7, R14). - Automatic order cancellation on revocation (typed revocation). Revoking a key could
cancel the open orders it placed (
placed_by, R13, would make them identifiable), and one could distinguish a “compromised key” revocation (cancel everything) from a “scheduled rotation” one (orders stay). Deliberately kept simple: revocation only removes authority (R6). Auto-cancel is right for compromise but wrong for routine rotation, and the compromise response is already compact — revoke, then cancel the revoked key’s orders viaget_my_ordersfiltered onplaced_by. A typed-revocation surface can be added later if operational experience demands it. - A minimum-free-balance bar on granting. A controller-configured per-token minimum
(
min_grant_balance, e.g. “at least 1 ICP free”, checked at grant time — a threshold, not a fee, and not locked afterwards) would add economic skin-in-the-game on top of the registration requirement and the grant cooldown (R7, R14). Deferred as a nice-to-have: the shipped gates already bound whitelist churn, and the threshold needs per-token configuration no other feature requires yet. - Subaccounts. They partition funds under one key; this feature separates keys over one
fund. Orthogonal (the
UserRegistrydoc already anticipates subaccounts as a key-type change).
Design Decisions
- Implicit caller resolution via a 1:1 registry (Hyperliquid model), not an explicit
on_behalf_ofargument (dYdX model). The canister keeps atrading → fundingmap; order and read endpoints resolve the effective account from the caller alone. Pros: zero API churn — every existing endpoint keeps its signature, and an integrator switches over by simply swapping the key its bot signs with; no per-call disambiguation; the hot path costs one map lookup. Cons: one key cannot serve two funding accounts — mitigated by minting another principal, which is free. The explicit-argument alternative and its trade-offs are in Discussed Alternatives. - Structural denial, not a permission mask. There is no per-key “can withdraw” flag that happens to be off: deposit / withdraw refuse trading accounts at the admission layer, and grants refuse principals that already hold an account. No reachable configuration lets a trading key move funds — mirroring Hyperliquid, where withdrawals are user-signed actions an agent’s signature can never satisfy. The CEX permission-mask alternative exists (Binance / Kraken / Coinbase) but every venue compensates for its misconfiguration risk with extra gates (IP allowlists, address allowlists, consensus approvals); with exactly one scope to express, the structural rule is smaller and safer.
- A principal is either a funding account or a trading account, never both. Funding accounts
are exactly the registered users (a
UserIdis acquired on first deposit, the only registering path); trading accounts can never deposit (R3), can never grant (R7), and must be unregistered at grant time (R7), so they never acquire one. This exclusivity is what makes implicit resolution unambiguous — “whose balance doesT’s order draw?” always has exactly one answer. - Resolution at the endpoint boundary;
owner = Feverywhere downstream. The trading / cancel / read entry points resolvecaller → effective accountonce, up front; order records, order events, settlement, and the trades feed are untouched and keep operating on the funding principal. Replay needs no delegation state for orders because events already carry the resolved owner (R2). - Grants are gated and rate-bounded; revocation never is. Every surveyed venue prices
whitelist mutations (dYdX authenticator ops cost gas, Hyperliquid’s
approveAgentis an on-chain action, CEX keys sit behind authenticated, rate-limited UIs) — while an IC update call costs its caller nothing, so an ungated grant would be the canister’s cheapest write-amplification surface (each successful mutation appends an event and writes the whitelist maps). Two layers close it: grant requires a registered funding account (the only registering path, deposit, has a real-token cost — R7), and successful grants are separated by a cooldown (R14). A third layer — a minimum free balance at grant time — is a deferred nice-to-have (see Non-goals).remove_trading_accountis exempt from all of it: it must stay instantly available as the compromise response, and it is inherently bounded by prior grants. - Attribute, don’t restrict. Order authority is account-scoped (any of
F’s keys can place and cancelF’s orders, R4) exactly as on every surveyed venue, but — going one step beyond the venues, none of which expose which API key placed an order — each action records the acting key (R13). This serves the compromise-forensics story that motivates the ticket (list and cancel precisely the rogue key’s orders) and is the prerequisite for future per-key volume caps (see the scoped-grants non-goal), while keeping rotation simple: authority never fragments per key, so revoking a key strands nothing. - Reuse the
Permissionspermit layer for admission; keep the whitelist data insideUserRegistry.permit_deposit/permit_withdrawalready thread the caller and today grant unconditionally — they become caller-aware and denying, so the existing capability-token discipline (a state change proves its admission check ran) extends to this feature at its natural seam. Grant / revoke follow theSetHaltevent pattern (controller-side admin ops recorded as events). The whitelist maps live as new fields onUserRegistry(each in its own stable-memory region), not as a separate struct: the grant invariant spans both theusersmap (“Fis registered”, “Tis unregistered”) and the whitelist (“Tis not already a delegate”), so one type enforces registered ⇔ funding account where the data lives — and a multi-map domain struct is the repo idiom (OrderHistory,TokenBalance). The whitelist does not live in the snapshot-persistedPermissionsstruct: that struct holds a handful of global flags, while the whitelist grows with the user count (see Discussed Alternatives).
Cross-exchange comparison
How the proposal lines up with the funding/trading separation on the surveyed venues. The takeaway: the chosen shape is Hyperliquid’s (the model the requester referenced), with the same structural guarantees; expiry is the one Hyperliquid feature deliberately deferred.
| Capability | Binance / Kraken / Coinbase | Hyperliquid | dYdX v4 | This spec |
|---|---|---|---|---|
| Mechanism | permission-scoped API keys | master approves agent keypairs | on-chain authenticators | funding account whitelists principals |
| Trading-only credential | key with trade scope only | agent (all it can do) | MsgPlaceOrder filter | trading account |
| Withdrawal by trading credential | possible if misconfigured | structurally impossible | excluded by default | structurally impossible |
| Credential holds funds | n/a (same account) | no | no | no (R3 + R7) |
| Acting identity → owner | key ↦ its account | agent registry, 1 master/agent | trader names owner per call | registry, 1 funding account/key (R7) |
| Grant / revoke | account UI | approveAgent (master-signed) | add/remove authenticator | add/remove_trading_account (F only) |
| Cap on credentials | ~30 keys | 1 + 3 named agents | unbounded | MAX_TRADING_ACCOUNTS_PER_USER |
| Expiry | 90-day auto-downgrade (Binance) | mandatory ≤ 180 d | none | none (non-goal) |
| Reads by trading credential | with read scope | no (query by master address) | n/a | yes, resolve to F (R5) |
| Cross-key cancel on one balance | yes (any trade-scope key) | yes (any agent) | yes (same subaccount) | yes (R4) |
| Orders attributed to the acting credential | no | no | not exposed | yes, placed_by (R13) |
| Cost to mutate the whitelist | authenticated UI + rate limits | on-chain action | gas per op | registered granter + cooldown (R7, R14) |
Sources:
- Hyperliquid:
Nonces and API wallets
(agents hold no funds, master-signed
approveAgent, 1 + 3 cap, ≤ 180-day validity, deregistration) · Exchange endpoint (the agent-signable vs user-signed action split that makes withdrawals structurally unsignable) · Privy recipe - dYdX v4: Permissioned keys (authenticators: signature check composed with message / subaccount / market filters) · How-to guide · Foundation blog
- Binance: API key permissions (trade / withdraw / internal-transfer as separate flags) · 2021 permission rule update (withdrawals require an IP allowlist; 90-day auto-downgrade) · Sub-account FAQ · Universal Transfer
- Kraken: API key permissions (deposit / withdraw / create / cancel as independent grants) · Creating an API key (optional expiry, IP allowlist, withdrawals only to pre-approved addresses)
- Coinbase:
Advanced Trade key permissions
(
can_view/can_trade/can_transfer) · Portfolios (keys bound to one portfolio) · Prime API overview · Prime transfers (consensus approval on transfers)
Implementation
Constraints
- The canister is event-sourced: state changes are recorded as events and replayed at
post_upgrade; stable-memory writes are gated byStableMemoryOptions::Writeso replay does not double-apply. Grant / revoke must follow the same pattern (R10). - Admission is proven by non-cloneable permit tokens from
state/permissions(permit_deposit(_caller)/permit_withdraw(_caller)currently ignore the caller and always grant — this feature makes them caller-aware). - Per-user state is keyed by the compact
UserIdminted byUserRegistry(canister/src/user); registration (get_or_register) happens only on deposit — the one economically gated entry point (a real ICRC-2 transfer with a ledger fee) — and reads use the non-registeringlookup. Granting requires prior registration (R7) and never registers anyone, so the invariant R3/R7 rely on is registered ⇔ has deposited ⇔ funding account, and the whitelist cannot be used to growUserRegistry(whose entries are never removed) for free. - Update endpoints assert
Mode::RestrictedToon the raw caller (assert_caller_is_allowed); this check stays raw (R12). MemoryIds 0–8 are in use (canister/src/storage); the whitelist takes the next free ids.
Candid API — canister/oisy_trade.did, libs/types
add_trading_account : (principal) -> (variant { Ok; Err : AddTradingAccountError });
remove_trading_account : (principal) -> (variant { Ok; Err : RemoveTradingAccountError });
get_my_trading_accounts : () -> (variant { Ok : vec principal; Err : GetMyTradingAccountsError }) query;
All three are DEFI-2801 error envelopes. AddTradingAccountError carries one request-error
variant per R7 precondition (granter not registered, self-grant, already a trading account,
already a registered user, caller is a trading account, too many trading accounts) plus, in
its TemporaryError class, the R14 cooldown and the in-flight-funding-operation rejection;
RemoveTradingAccountError covers “not your trading account”. DepositError and WithdrawError gain a variant denying funding operations
to trading accounts (R3), added inside the envelope’s opt variant request-error class —
the extension point DEFI-2801 built in exactly for this: a client compiled against the old
interface decodes the unknown variant as null and falls back to the envelope’s message,
so the addition is backward compatible (the system is launched; no Candid-breaking change is
acceptable). The new endpoints are purely additive. The implementation PR updates the repo’s
candid backward-compat expected diff accordingly.
MAX_TRADING_ACCOUNTS_PER_USER = 4 (Hyperliquid grants 1 unnamed + 3 named agents; no known
integrator needs more — trivially raisable later). TRADING_ACCOUNT_GRANT_COOLDOWN = 1 hour
(a code constant — key rotation happens on a timescale of weeks, so an hour between grants
costs legitimate users nothing).
For attribution (R13), OrderRecord gains placed_by : opt principal (absent = placed by the
owner itself), surfaced by get_my_orders. Adding an opt field to a returned record is a
backward-compatible Candid evolution; on the stable-memory side it is a new trailing minicbor
field decoding absent as None (an Option field — minicbor’s absent-field behavior, as
last_updated_at already relies on; codec icrc_cbor::principal::option), so records written before this
feature still decode — the post-launch requirement.
Whitelist registry — canister/src/user
UserRegistry<M> gains two fields, each in its own new stable region (MemoryId 9 and 10),
following the repo’s multi-map domain-struct idiom (OrderHistory, TokenBalance):
trading_accounts: StableBTreeMap<PrincipalKey, TradingGrant, M>— trading principal → grant. The hot lookup: onegetper order / read call.TradingGrantis a one-field minicbor struct holding the funding principal (a struct, not a bare principal, so the scoped-grants evolution adds#[cbor(default)]fields instead of migrating the value type; a principal rather than aUserIdbecause everything downstream — ownership checks, order records, events — compares principals, and the fundingUserIdis then resolved exactly as today).trading_accounts_by_funding: StableBTreeMap<UserId, TradingAccountList, M>— fundingUserId→ the bounded list (≤MAX_TRADING_ACCOUNTS_PER_USER) of its trading principals pluslast_granted_at(the R14 cooldown anchor), servingget_my_trading_accounts, the R7 cap check, and the cooldown check. A bounded inline list, not a(UserId, principal)range-scan index: theCompositeIdmachinery assumes fixed-width components (aPrincipalis variable-length), and a cardinality of at most 4 does not earn a scan index. Grant requiresFto be registered already (R7), so it always has aUserIdto key by.
The two value types:
#![allow(unused)]
fn main() {
/// A trading account's standing authorization.
pub struct TradingGrant {
/// The funding account this key acts for — the result of `resolve_account`.
#[cbor(n(0), with = "icrc_cbor::principal")]
funding: Principal,
}
/// A funding account's whitelist and its grant-cooldown anchor.
pub struct TradingAccountList {
/// The whitelisted trading principals, at most
/// `MAX_TRADING_ACCOUNTS_PER_USER` (`get_my_trading_accounts`, the R7 cap check).
#[n(0)]
accounts: Vec<Principal>,
/// Time of the most recent successful grant — the R14 cooldown anchor.
/// Per funding account, not per key: R14 bounds the account's grant rate.
#[n(1)]
last_granted_at: Timestamp,
}
}
Both are minicbor-encoded with CBOR-based Storable impls (Bound::Unbounded), like
PrincipalKey. A TradingAccountList entry is created by the first grant (which always sets
last_granted_at) and is never removed, only shrunk: if revoking the last key deleted the
entry, the cooldown anchor would vanish and revoke-all → re-grant would bypass R14.
The whitelist lives on UserRegistry (not in a separate struct) because the grant invariant
spans both maps and users: grant checks “F is registered”, “T is unregistered”, and
“T / F are not delegates” in one place, keeping registered ⇔ funding account enforced by
the type that owns the data. Registration itself stays deposit-only — grant reads users but
never writes it.
API on UserRegistry: grant(funding: Principal, trading: Principal, now: Timestamp) -> Result<(), GrantError> (the identity, cap, and cooldown checks — R7 and R14),
revoke(funding: Principal, trading: Principal) -> Result<(), RevokeError>,
resolve_account(caller: Principal) -> Principal (delegate → funding principal, else the caller),
is_trading_account(&Principal) -> bool (the R3 deny check),
trading_accounts_of(funding: Principal) -> Vec<Principal> (R9).
State wiring — canister/src/state
- Resolution.
Statedelegates touser_registry.resolve_account(caller). Applied once, at the entry of:validate_limit_order/record_limit_order(order owner, balance reservation — R2),validate_cancel_limit_order(ownership check — R4),get_balances,get_user_order(s),get_user_trades/get_user_order_trades(R5). Everything downstream is unchanged. - Admission.
permit_deposit/permit_withdrawtake the caller’s delegation status and returnResult<PreAsyncPermit, UnauthorizedError>with a newUnauthorizedErrorvariant;deposit/withdrawmap it into the R3 error before any ledger call.permit_tradingis unchanged (halt checks are caller-agnostic). - Grant / revoke. New event types
AddTradingAccountEvent { funding, trading }andRemoveTradingAccountEvent { funding, trading }, handled instate/auditlikeSetHalt: the endpoint validates the preconditions synchronously — the identity, cap, and cooldown checks inUserRegistry, plus “Thas no in-flight funding operation” against theUserOpGuardstate (R7, R14) — records the event, and the handler applies it to theUserRegistrywhitelist maps under theWritegate (R10). Rejected calls record nothing. - Attribution (R13). The order-placement path threads the raw caller alongside the resolved
owner:
AddLimitOrderEventgainsplaced_by: Option<Principal>(Nonewhen the caller is the owner) andrecord_limit_orderstores it on theOrderRecord; the cancel event gainscanceled_by: Option<Principal>likewise. Optional trailing fields (absent decodes asNone) keep the event log and order history decoding pre-existing entries (post-launch compatibility); replay is byte-faithful since the events carry the attribution themselves.
Endpoints — canister/src/lib.rs, canister/src/main.rs
Thin #[ic_cdk::update] / #[ic_cdk::query] wrappers in main.rs over business functions in
lib.rs, as for every existing endpoint. The management endpoints act on the raw caller (R9)
and assert restricted mode like other updates (R12).
Delivery / PR sequence
Six stacked PRs, each independently mergeable / compilable / testable. Funding-operation denial
(PR 3) deliberately precedes every caller-resolution PR (4–6): once reads resolve, a balance
T acquired for itself would become unreachable, so T must be unable to deposit before any
endpoint resolves.
| # | Summary | Details | Requirements covered |
|---|---|---|---|
| 1 | Trading-account registry | UserRegistry whitelist extension (TradingGrant, TradingAccountList, two new memory regions), add_trading_account + get_my_trading_accounts, AddTradingAccountEvent with replay handling, envelope errors. All grant preconditions: registered granter, self-grant, 1:1, unregistered T, granter not a delegate, and the MAX_TRADING_ACCOUNTS_PER_USER cap. Whitelist recorded, enforced nowhere. | R1 (grant + list), R7, R9, R10, R11 |
| 2 | Revocation + grant cooldown | remove_trading_account + RemoveTradingAccountEvent; the 1 h grant cooldown (last_granted_at anchor, rejected as TemporaryError, no event on rejection); revocation itself never rate-limited. The minimum-free-balance gate stays out of scope (nice-to-have — see Non-goals). | R1 (revoke), R6 (mechanics), R14 |
| 3 | Funding-operation denial | permit_deposit / permit_withdraw become caller-aware; deposit / withdraw by a trading account fail with the dedicated envelope variant before any ledger call. | R3 |
| 4 | Resolution on reads | get_balances, get_my_orders, get_my_trades resolve the caller to its funding account. | R5 |
| 5 | Resolution on order placement | add_limit_order resolves the order owner (validation, reservation, record, event); placed_by attribution on OrderRecord, the placement event, and get_my_orders. | R2, R8, R13 (placement) |
| 6 | Resolution on cancel | cancel_limit_order checks ownership against the resolved account; canceled_by on the cancel event; end-to-end integration lifecycle (deposit → grant → trade → revoke → stranger). | R4, R6 (end-to-end), R12, R13 (cancel) |
Discussed Alternatives
- Explicit
on_behalf_ofargument (dYdX-style). Order and read calls name the funding account; the canister checks the caller against that account’s whitelist. Pros: one trading key can serve several funding accounts; the trading principal could keep its own separate account; no claim-a-principal grief (acting forFrequiresF’s grant and namingF). Cons: every order and read endpoint changes signature (or grows an optional field), all clients must thread the argument, and the common case (one funder) pays per-call disambiguation for a flexibility nobody asked for — the requester’s ask is exactly the Hyperliquid shape, and extra keys are free. Rejected; the registry chosen here does not preclude adding an explicit-argument path later if a one-key-many-funders use case materializes. - Alias in
UserRegistry— registerTunderF’sUserId. Seductively small: resolution would fall out of the existinglookup. The obvious objection — revocation requires removal, which thelen()-derived id assignment forbids — is fixable (store the next id in aStableCell), but the fixes stop there. First, an undifferentiated alias makesTa full alias ofFincludingwithdraw(Tcould sendF’s funds to its own wallet — the exact loss this feature exists to prevent), so a delegate marker is needed regardless; adding one means either re-creating thetrading_accountsmap or breaking the launchedusersmap’s value schema. Second, ownership checks, order records, and events compare principals (OrderRecord.owneris launched CBOR and Candid schema), so cancel-by-Tstill needs aT → F-principal mapping. Third, the cap, listing, and cooldown need the per-funding structure anyway. The alias thus converges to the chosen design plus an extra region and a migration risk. - Store the whitelist inside the
Permissionsstruct. Keeps all authorization data in one place, butPermissionsis heap state serialized into the snapshot and sized for a handful of global flags; a per-user whitelist grows with the user count and belongs in stable memory like balances and order history. The admission decision still flows throughPermissionspermits — only the data lives onUserRegistry. - Per-key permission mask (CEX-style
can_withdrawflag). More general (arbitrary scope combinations later), but one misconfiguration away from fund loss — which is why every CEX layers compensating gates on top (IP allowlists, withdrawal-address allowlists, Coinbase Prime’s consensus approvals). We need exactly one scope; the structural rule is strictly smaller and cannot be misconfigured. - ICRC-2-style allowance. Allowances grant a spender the right to move a bounded amount of funds — the opposite of the requirement (standing order-placement authority with no fund-movement rights). Notional caps could later layer on top of the whitelist (non-goal).
- Two-step grant (trading principal must accept). Closes the “claim a fresh principal”
grief but adds a pending-grant state machine (accept / decline / expire) for a residual that
R7 already bounds to unregistered principals and whose cost falls on the granter.
Hyperliquid’s
approveAgentis likewise unilateral. Rejected; documented as an accepted residual (see Non-goals). - Compact ids (
Seq) instead of principals in the whitelist and attribution. The codebase’s compaction rule — per-user stable keys store an 8-byte id, not the ~30-byte identity — pays on unbounded cardinality, and the design already applies it where it holds (trading_accounts_by_fundingisUserId-keyed). Everywhere else in the whitelist it cannot pay: thetrading_accountskey is the caller lookup;TradingGrant.fundingas aUserIdwould need a newUserId → Principalreverse region (~38 B/user) to save ≤ 88 B per granting user, since resolution must yield the principal that the launchedownerschema and the ownership checks compare; the list and the grant events are bounded (≤ 4 per user, R14 rate-bounded). The one candidate that could pay isplaced_by(unbounded, present on most bot-placed orders): aSeq-minted trading-account id + immortal reverse map would shrink ~31 B to ~9 B per attributed record. Deferred: it adds an id family, a reverse region, and read-time joins whileowner— the larger principal on the same record — stays principal-typed; the honest version is a holistic compact-identity migration (owner + attribution) with a natural home when subaccounts change the registry key type.