Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help


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 F can whitelist a principal T via add_trading_account. From then on T may place and cancel orders acting on F’s account. F can revoke via remove_trading_account.
  • R2 — Orders act on the funding account. An order placed by T is economically F’s order: validated against and reserved from F’s free balance, recorded with owner = F, visible in F’s get_my_orders, and its fills settle into F’s balances. For matching and settlement it behaves exactly as if F had placed it (order events carry F as the owner, so event-log replay is unaffected); the acting key stays visible for audit (R13).
  • R3 — Funding operations denied. deposit and withdraw called by a currently whitelisted T fail 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. F and any of F’s trading accounts may cancel F’s open orders. Any other principal — including a trading account of a different funding account — gets NotOrderOwner, exactly as today. Cancel authority is deliberately shared across F’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, and get_my_trades called by T return F’s data, exactly as if F had called.
  • R6 — Revocation is immediate. After remove_trading_account(T), calls by T are treated as coming from an ordinary unknown principal. F’s open orders — including those T placed — are unaffected and stay open.
  • R7 — Grant preconditions. add_trading_account(T) fails if any of:
    • F is not a registered user (has never deposited) — granting requires an existing, economically established account and never creates one;
    • T == F (self-grant);
    • T is already a trading account, of F or of anyone else — a trading account maps to exactly one funding account;
    • T is already a registered user (has ever deposited) — a trading key must be a fresh principal;
    • F is 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);
    • F already has MAX_TRADING_ACCOUNTS_PER_USER trading accounts;
    • T has an in-flight deposit or withdraw (a retryable rejection, the envelope’s TemporaryError class). This closes a race on deposit’s await window: without it, “T is unregistered” could pass while T’s first icrc2_transfer_from awaits the ledger, and the completing deposit would then register and credit T after 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 (the UserOpGuard state) 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 if T is not currently F’s trading account.
  • R8 — Funding account unaffected. F retains full authority — deposit, withdraw, trade, cancel, queries — regardless of how many trading accounts it has whitelisted.
  • R9 — Introspection. get_my_trading_accounts returns 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: OrderRecord and the order-placement event gain placed_by (optional principal; absent = placed by the funding account itself), and the cancel event records canceled_by likewise. placed_by is exposed through get_my_orders, so after a key compromise F can 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_account fails with a retryable error (the envelope’s TemporaryError class) if less than TRADING_ACCOUNT_GRANT_COOLDOWN has elapsed since F’s previous successful grant. remove_trading_account is 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_account gains 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 has placed_by to 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_account is unilateral, like Hyperliquid’s approveAgent. Accepted residual: F can claim any unregistered principal as its trading key. The consequence falls on F (the claimed key gains trading power over F’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 via get_my_orders filtered on placed_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 UserRegistry doc 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_of argument (dYdX model). The canister keeps a trading → funding map; 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 UserId is 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 does T’s order draw?” always has exactly one answer.
  • Resolution at the endpoint boundary; owner = F everywhere downstream. The trading / cancel / read entry points resolve caller → effective account once, 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 approveAgent is 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_account is 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 cancel F’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 Permissions permit layer for admission; keep the whitelist data inside UserRegistry. permit_deposit / permit_withdraw already 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 the SetHalt event pattern (controller-side admin ops recorded as events). The whitelist maps live as new fields on UserRegistry (each in its own stable-memory region), not as a separate struct: the grant invariant spans both the users map (“F is registered”, “T is unregistered”) and the whitelist (“T is 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-persisted Permissions struct: 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.

CapabilityBinance / Kraken / CoinbaseHyperliquiddYdX v4This spec
Mechanismpermission-scoped API keysmaster approves agent keypairson-chain authenticatorsfunding account whitelists principals
Trading-only credentialkey with trade scope onlyagent (all it can do)MsgPlaceOrder filtertrading account
Withdrawal by trading credentialpossible if misconfiguredstructurally impossibleexcluded by defaultstructurally impossible
Credential holds fundsn/a (same account)nonono (R3 + R7)
Acting identity → ownerkey ↦ its accountagent registry, 1 master/agenttrader names owner per callregistry, 1 funding account/key (R7)
Grant / revokeaccount UIapproveAgent (master-signed)add/remove authenticatoradd/remove_trading_account (F only)
Cap on credentials~30 keys1 + 3 named agentsunboundedMAX_TRADING_ACCOUNTS_PER_USER
Expiry90-day auto-downgrade (Binance)mandatory ≤ 180 dnonenone (non-goal)
Reads by trading credentialwith read scopeno (query by master address)n/ayes, resolve to F (R5)
Cross-key cancel on one balanceyes (any trade-scope key)yes (any agent)yes (same subaccount)yes (R4)
Orders attributed to the acting credentialnononot exposedyes, placed_by (R13)
Cost to mutate the whitelistauthenticated UI + rate limitson-chain actiongas per opregistered granter + cooldown (R7, R14)

Sources:

Implementation

Constraints

  • The canister is event-sourced: state changes are recorded as events and replayed at post_upgrade; stable-memory writes are gated by StableMemoryOptions::Write so 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 UserId minted by UserRegistry (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-registering lookup. 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 grow UserRegistry (whose entries are never removed) for free.
  • Update endpoints assert Mode::RestrictedTo on 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: one get per order / read call. TradingGrant is 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 a UserId because everything downstream — ownership checks, order records, events — compares principals, and the funding UserId is then resolved exactly as today).
  • trading_accounts_by_funding: StableBTreeMap<UserId, TradingAccountList, M> — funding UserId → the bounded list (≤ MAX_TRADING_ACCOUNTS_PER_USER) of its trading principals plus last_granted_at (the R14 cooldown anchor), serving get_my_trading_accounts, the R7 cap check, and the cooldown check. A bounded inline list, not a (UserId, principal) range-scan index: the CompositeId machinery assumes fixed-width components (a Principal is variable-length), and a cardinality of at most 4 does not earn a scan index. Grant requires F to be registered already (R7), so it always has a UserId to 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. State delegates to user_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_withdraw take the caller’s delegation status and return Result<PreAsyncPermit, UnauthorizedError> with a new UnauthorizedError variant; deposit / withdraw map it into the R3 error before any ledger call. permit_trading is unchanged (halt checks are caller-agnostic).
  • Grant / revoke. New event types AddTradingAccountEvent { funding, trading } and RemoveTradingAccountEvent { funding, trading }, handled in state/audit like SetHalt: the endpoint validates the preconditions synchronously — the identity, cap, and cooldown checks in UserRegistry, plus “T has no in-flight funding operation” against the UserOpGuard state (R7, R14) — records the event, and the handler applies it to the UserRegistry whitelist maps under the Write gate (R10). Rejected calls record nothing.
  • Attribution (R13). The order-placement path threads the raw caller alongside the resolved owner: AddLimitOrderEvent gains placed_by: Option<Principal> (None when the caller is the owner) and record_limit_order stores it on the OrderRecord; the cancel event gains canceled_by: Option<Principal> likewise. Optional trailing fields (absent decodes as None) 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.

#SummaryDetailsRequirements covered
1Trading-account registryUserRegistry 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
2Revocation + grant cooldownremove_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
3Funding-operation denialpermit_deposit / permit_withdraw become caller-aware; deposit / withdraw by a trading account fail with the dedicated envelope variant before any ledger call.R3
4Resolution on readsget_balances, get_my_orders, get_my_trades resolve the caller to its funding account.R5
5Resolution on order placementadd_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)
6Resolution on cancelcancel_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_of argument (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 for F requires F’s grant and naming F). 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 — register T under F’s UserId. Seductively small: resolution would fall out of the existing lookup. The obvious objection — revocation requires removal, which the len()-derived id assignment forbids — is fixable (store the next id in a StableCell), but the fixes stop there. First, an undifferentiated alias makes T a full alias of F including withdraw (T could send F’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 the trading_accounts map or breaking the launched users map’s value schema. Second, ownership checks, order records, and events compare principals (OrderRecord.owner is launched CBOR and Candid schema), so cancel-by-T still needs a T → 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 Permissions struct. Keeps all authorization data in one place, but Permissions is 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 through Permissions permits — only the data lives on UserRegistry.
  • Per-key permission mask (CEX-style can_withdraw flag). 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 approveAgent is 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_funding is UserId-keyed). Everywhere else in the whitelist it cannot pay: the trading_accounts key is the caller lookup; TradingGrant.funding as a UserId would need a new UserId → Principal reverse region (~38 B/user) to save ≤ 88 B per granting user, since resolution must yield the principal that the launched owner schema 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 is placed_by (unbounded, present on most bot-placed orders): a Seq-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 while owner — 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.