Skip to content

feat(perps-controller): sign without KeyringController and with a HyperLiquid agent - #10559

Merged
abretonc7s merged 33 commits into
mainfrom
feat/perps-account-signer
Sep 29, 2026
Merged

abretonc7s merged 33 commits into
mainfrom
feat/perps-account-signer

Conversation

@abretonc7s

@abretonc7s abretonc7s commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Explanation

PerpsController signs through KeyringController messenger actions, so a client without a keyring (a web trading terminal on an EIP-1193 wallet, later Electron) can't use it. It also asks the main wallet to sign every HyperLiquid order, which an external wallet can't do: HyperLiquid L1 actions use chainId 1337, and wallets reject a typed-data chainId that doesn't match the connected chain.

This PR adds two optional hooks and keeps the keyring path unchanged when neither is set:

  • PerpsPlatformDependencies.accountSigner: signTypedData, signPersonalMessage, optional isReady and requiresSignatureConfirmation. When set, HyperLiquid typed data and Lighter personal_sign go through it and no KeyringController:* action is called. The signing address still comes from the selected account.
  • HyperLiquid agent signing:
    • providerCredentials.hyperliquid.getAgentSigner(account) resolves an approved agent key for a main account and network, and L1 actions (orders, cancels, leverage) are signed by it.
    • User-signed actions (builder fee, withdraw) always stay on the main account.
    • PerpsController:setAgentSigner binds or pins an agent explicitly, and PerpsController:clearAgentSigners forgets them, for example on lock.
    • An agent the venue rejects (revoked or expired) is dropped and the write fails with KEYRING_LOCKED; optional onAgentRejected(account, agentAddress) tells the client.
    • Approving the agent stays the client's job.
  • PerpsController:prepareTradingWallet runs the deferred setup (HL migration, builder fee, referral; Lighter key registration) before the first order, so an external or hardware wallet signs it in one session, and reports readiness.

Breaking: removes LighterPersonalSigner and LighterAuthConfig.personalSigner / l1Address. The controller never forwarded them, so they had no effect; accountSigner.signPersonalMessage replaces them.

Agent handling:

  • the agent is bound to the main account and network it was approved for;
  • agent resolution is race-safe across account and network switches;
  • clearing agents doesn't tear down WebSockets;
  • the action types are generated rather than hand-edited.

References

Validation

  • Unit and integration tests for the account-signer and agent paths: 4,012 passing on Node 24 and 4,008 on Node 22 at the head 3e028d469f, coverage thresholds met, including a test of the new public exports through src/index.ts. The agent-signing integration test runs the real controller, provider and client service against a faked SDK. Every review fix was checked red/green against the reverted source
  • Regression check: main's own test suite (3,803 tests) run against this branch's source passes, except the 2 tests of the removed Lighter headless signer. The only test-side changes needed were the renamed methods of the internal HyperLiquidWalletService in mocks (isKeyringUnlocked → isMainAccountSignerReady, isSelectedHardwareWallet → requiresSignatureConfirmation, same answers for keyring hosts) and the SDK error class in one SDK mock
  • HyperLiquid testnet through the core harness on 110be95a9c (the later commits only add tests and shorten doc comments) (market lifecycle with close, limit lifecycle with cancel, order edit, TP/SL, close position):
    • KeyringController messenger (the Mobile and Extension wiring): 5/5 pass, 8 to 11 KeyringController calls per recipe
    • accountSigner: 5/5 pass, 0 KeyringController calls, every signature through accountSigner
    • agent: 5/5 pass, 0 KeyringController and 0 main-account signatures; every order, cancel, edit, TP/SL and close signed by the agent key
    • accountSigner mode on origin/main (d063750206): every run stops at the harness's own guard, because that checkout does not export PerpsAccountSigner, before any order. It shows the mode is unavailable on main, not a difference in main's trading
  • Agent lifecycle on HyperLiquid testnet on 110be95a9c, with a fresh agent key:
    • the main account approved it through accountSigner as a named agent valid for one hour, and extraAgents listed it with the requested validUntil
    • prepareTradingWallet returned ready: true without any signature
    • clearAgentSigners left the one WebSocket open (none closed) while price updates continued, and the next L1 action asked getAgentSigner again
    • after the agent was revoked (zero address, same name), the next order failed with KEYRING_LOCKED, onAgentRejected(account, agentAddress) was called, and the agent was dropped and asked for again
  • approveAgent signature chain: HyperLiquid testnet accepted approveAgent signed with signature chain 0x1, 0xa4b1 (Arbitrum One), 0x66eee (Arbitrum Sepolia) and 0xaa36a7 (Sepolia), each with a matching EIP-712 domain chainId. Whether a wallet signs a domain chainId other than its active network was not tested
  • Mobile with this change as a Yarn patch of 459592184c (keyring path, chore(perps): patch perps-controller with account and agent signing metamask-mobile#36966): type-check, all Perps unit (9,513) and integration (50) tests, HyperLiquid testnet journeys (smoke, position and order convergence, Pro TP/SL, order edit) and Lighter venue-key registration pass. The Lighter Lite trade journey could not run: its recipe checks an A/B-test flag that is no longer served

Client note: Mobile's Perps integration harness mocks the internal HyperLiquidWalletService, so the version bump needs the two renamed methods in that mock (done in MetaMask/metamask-mobile#36966).

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

High Risk
Changes core trading signing paths (HyperLiquid L1 vs user-signed, Lighter registration), adds agent lifecycle and breaking Lighter config removal, with broad touch on order/cancel/TP-SL error handling.

Overview
Adds optional PerpsPlatformDependencies.accountSigner so hosts without KeyringController can sign HyperLiquid EIP-712 and Lighter personal_sign from the selected account, with KEYRING_LOCKED when the signer is not ready and optional deferral of init-time prompts via requiresSignatureConfirmation.

Adds HyperLiquid agent signing for L1 actions only: getAgentSigner, controller setAgentSigner / clearAgentSigners, venue rejection handling (onAgentRejected, agent eviction, KEYRING_LOCKED instead of “unknown wallet”), plus HyperLiquidWalletService routing (agent for Exchange/Agent typed data, main account for user-signed flows). New prepareTradingWallet (controller, aggregated provider, HyperLiquid, Lighter) runs migration, builder fee, referral, and Lighter key registration ahead of the first order and returns ReadyToTradeResult.

Breaking: removes LighterPersonalSigner and LighterAuthConfig.personalSigner / l1Address in favor of accountSigner. HyperLiquid writes that fail because the signer is unavailable are classified as KEYRING_LOCKED, often without provider/TradingService error logging; batch cancelOrders reports per-order outcomes when some entries succeed. Docs/changelog and new exports (PerpsAccountSigner, PerpsAgentSigner, L1 EIP-712 constants) accompany the API surface.

Reviewed by Cursor Bugbot for commit 3e028d4. Bugbot is set up for automated code reviews on this repo. Configure here.

abretonc7s and others added 9 commits September 29, 2026 07:34
Clients without a KeyringController can now sign through their own wallet.
PerpsPlatformDependencies gains an optional accountSigner (signTypedData,
signPersonalMessage, isReady, isHardwareWallet). When it is set, the
HyperLiquid and Lighter wallet services sign through it instead of the
KeyringController:* messenger actions. Clients that do not set it keep the
current behaviour.

isReady() returning false fails with the existing KEYRING_LOCKED code, and
isHardwareWallet() drives the same prompt deferral as the keyring-type check.
BREAKING CHANGE: remove the LighterPersonalSigner type and the
personalSigner and l1Address fields of LighterAuthConfig.

PerpsController never forwarded these fields to the Lighter provider, so
they had no effect for controller clients. accountSigner.signPersonalMessage
replaces the injected signer, and the L1 address comes from the
messenger's selected account. The Lighter e2e now signs through
accountSigner with a selected-account messenger.
- Make signPersonalMessage required. When accountSigner is set, neither
  wallet service calls KeyringController, and isReady gates both.
- Default isHardwareWallet to the selected account's keyring type instead
  of false.
- Branch to accountSigner inside the Hyperliquid wallet adapter, where the
  payload is typed, instead of casting in the keyring path.
- Cover real provider flows with a keyringless messenger (the Hyperliquid
  unified-account migration and the Lighter ChangePubKey registration), and
  cover the controller passing accountSigner to both providers.
… agent

Adds agent (API wallet) signing on top of accountSigner. A host-owned
PerpsAgentSigner, resolved through providerCredentials.hyperliquid
.getAgentSigner or set at runtime with PerpsController:setAgentSigner,
signs L1 actions (Agent type over the Exchange domain). User-signed actions
stay on the main account, through accountSigner or the keyring.

Switching the agent rebuilds only the SDK exchange client over the existing
HTTP transport (HyperLiquidClientService.setWallet), so live WebSocket
subscriptions keep running. PerpsController:prepareTradingWallet runs the
deferred trading-readiness steps before the first order.

Also addresses self-review round 2: document the EIP712Domain entry in
PerpsTypedDataPayload, share the payload type with the SDK wallet params,
log the Lighter signing address on both paths, strengthen the not-ready
tests and scope the isHardwareWallet changelog note to HyperLiquid.

Co-authored-by: Monte Lai <monte.lai@consensys.net>
Resolve the HyperLiquid agent when an L1 action is signed, keyed by the
selected main account and network, instead of swapping the SDK wallet.
An agent now never signs for another account or network, reads never call
getAgentSigner (whose failure only fails the L1 action that needed it), and
the exchange-client swap is no longer needed.

- getAgentSigner receives { mainAddress, isTestnet }.
- setAgentSigner binds to the selected account and network.
- prepareTradingWallet resolves a ReadyToTradeResult that is not ready
  while a step still needs a signature.
- Verify agent routing through the SDK's own signing functions.

Co-authored-by: Monte Lai <monte.lai@consensys.net>
- setAgentSigner(account, agentSigner) takes the main account and network
  the agent is approved for, so a pending account switch or re-initialization
  cannot attach it to another account. getAgentSigner receives the same
  PerpsAgentAccount.
- Keep only non-null getAgentSigner answers; null and failures are asked
  again at the next L1 action. A binding set while an answer is pending wins.
- Hold the agent resolver in HyperLiquidWalletService so every wallet adapter
  it creates, including the subscription service's, routes L1 actions.
- Log resolver failures once (the migration path reports them), move the L1
  action constants to hyperLiquidConfig, and document what ready means.
- Recover signers from real SDK signatures and cover the resolver races.

Co-authored-by: Monte Lai <monte.lai@consensys.net>
- Add PerpsController:clearAgentSigners (and optional
  PerpsProvider.setAgentSigner / clearAgentSigners) so a host can stop agent
  signing on lock and let getAgentSigner answer again after unlock;
  setAgentSigner(account, null) pins the main account until then.
- Treat a failed or synchronously throwing getAgentSigner as retryable
  (AgentSignerUnavailableError), so the referral write is retried instead of
  being recorded as failed for the session.
- prepareTradingWallet reports KEYRING_LOCKED whenever the main-account
  signer is not ready and logs unexpected failures; Lighter prepares its
  venue-key registration and the aggregated provider prepares every provider.
- Export the L1 action constants from the package root.

Co-authored-by: Monte Lai <monte.lai@consensys.net>
- clearAgentSigners discards getAgentSigner answers still pending, and an
  answer superseded by a clear or a newer binding is resolved again, so a
  failure always surfaces as the retryable AgentSignerUnavailableError.
- clearAgentSigners is a synchronous no-op without an initialized
  HyperLiquid provider, so a wallet-lock handler can always call it.
- A referral write that failed on the agent signer keeps trading setup
  retryable, so prepareTradingWallet reports not ready and retries it.
- Lighter prepareTradingWallet reports KEYRING_LOCKED whenever the main
  signer is not ready and logs failures; the aggregated provider isolates
  each provider's failure.

Co-authored-by: Monte Lai <monte.lai@consensys.net>
…require it

The SDK ships ES modules only, and Jest can require them only on Node 24.9 or newer, so the SDK signing suite failed to load in the Node 22 CI job. It now loads the SDK with jest.requireActual and runs on Node 24.9+; the other suites in the file still run on Node 22.
…t retryable failures

- PerpsController keeps setAgentSigner bindings (AgentBindings) across
  HyperLiquid provider re-creation and before init; a changed binding drops
  the agents the provider already resolved. PerpsProvider.setAgentSigner is
  removed; the provider only caches getAgentSigner answers.
- An agent whose signTypedData rejects is retryable like a failing
  getAgentSigner. Neither is logged as an error during the referral write or
  the silent unified-account migration.
- Lighter prepareTradingWallet logs only unexpected failures, with the
  original error, and the aggregated provider logs a provider that throws.
- Document the chain IDs HyperLiquid payloads carry for accountSigner and
  what isHardwareWallet means for interactive wallets.
…ailures retryable

- An agent the venue rejects as unknown (revoked or expired) is evicted,
  with a setAgentSigner binding to it, so the next L1 action asks
  getAgentSigner again. The rejection no longer reads as a wallet with no
  HyperLiquid account in orders, the silent migration or the referral.
- Orders and other exchange writes that fail because the keyring is locked
  or the agent is unavailable fail with KEYRING_LOCKED, and a failed order
  for that reason is not reported as an error.
- A provider waiting on another provider's referral attempt makes its own
  when that attempt cached nothing.
- Lighter prepareTradingWallet resolves not ready without an error when the
  user declines or has no Lighter account yet, and logs a missing signer
  bridge. The aggregated provider tags logged errors with the network.
- Rename PerpsAccountSigner.isHardwareWallet to
  requiresSignatureConfirmation, which covers interactive wallets too.
- Add a controller-level test that drives agent bindings through a real
  HyperLiquid provider and wallet service.
abretonc7s added a commit to MetaMask/metamask-mobile that referenced this pull request Sep 29, 2026
Carries MetaMask/core#10559 as a Yarn patch of @metamask/perps-controller
18.0.1 (built dist only: 18.0.1 plus that PR's source changes) until the
release is published. Mobile sets none of the new hooks and keeps signing
through KeyringController.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts
…real wallet service and caches

Moves the account-signer block out of the account-mode suite, whose file-wide mocks replaced the wallet service and the signing caches. The new suite mocks only the client, subscription and SDK boundaries and clears the real caches before each test, so the idempotency and retry tests read the cache the provider writes.
…ble cancels

- Match a venue rejection only against the agent of the selected account
  and network that signed the action, so an agent shared across accounts is
  evicted for that account alone.
- Cancel and batch cancel no longer log a signer that could not sign
  (KEYRING_LOCKED) as an error.
- Lighter prepareTradingWallet reports KEYRING_LOCKED when the signer locks
  while the venue key is being registered.
…write

- Edits, position closes, TP/SL and margin updates evict a rejected agent
  and fail with KEYRING_LOCKED without being logged, like orders and
  cancels. A TP/SL update whose cancel the signer could not sign keeps the
  old protection instead of reporting it lost. TradingService no longer
  logs KEYRING_LOCKED results.
- A rejection of an agent replaced while its action was in flight is still
  recognized, from every agent address the provider signed with.
- Add providerCredentials.hyperliquid.onAgentRejected so the client can
  re-check its approval, and document what KEYRING_LOCKED covers.
- HyperLiquid prepareTradingWallet returns KEYRING_LOCKED before running
  any setup while the signer is not ready, and the referral write no longer
  logs a locked keyring.
- Rename HyperLiquidWalletService.isKeyringUnlocked to
  isMainAccountSignerReady, like the Lighter wallet service.
- Tests: controller-level agent flows through the real provider, every
  write path, shared agent fixtures, and stronger readiness assertions.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5b86138. Configure here.

Comment thread packages/perps-controller/src/providers/HyperLiquidProvider.ts Outdated
…nd TP/SL clears

- Scale cancels report a signer failure from either batch (order IDs or
  client order IDs) as KEYRING_LOCKED, keep the ladder registered for a
  retry, and evict a rejected agent.
- Clearing TP/SL shares the pre-cancel with the replacement path, so a
  signer failure keeps the protection and fails with KEYRING_LOCKED.
- A chase tick whose cancel is rejected evicts the agent.
- Attribute a rejected agent to the account it signed for, not the
  account selected when the rejection arrives.
- One helper handles signer failures for every write; the wallet
  service's isSelectedHardwareWallet becomes requiresSignatureConfirmation.
- The controller clears the agents of every provider.
- Lighter prepareTradingWallet returns a quiet not-ready result when its
  session is cancelled; the aggregated provider logs with the provider
  context convention; TradingService batch logs count only the failures
  they report.
- Tests use the shared agent fixtures and pin the callback arguments.
…through the SDK boundary

- Withdrawals and transfers between DEXs fail with KEYRING_LOCKED
  without logging when the signer cannot sign.
- The referral retries after any signer failure, a locked keyring
  included, so preparation is not reported ready without it.
- prepareTradingWallet reports KEYRING_LOCKED when the signer locked
  while a step failed (HyperLiquid and Lighter); Lighter reports a
  cancelled session as PROVIDER_LIFECYCLE_STALE.
- Cancel batches classify signer failures themselves; #mapError has no
  side effects, and rejections reported in status entries still drop
  the agent.
- One agent key helper; named patterns; the aggregated provider tags a
  testnet-pinned Lighter with its network.
- The agent-signing integration test mocks only the SDK and drives
  writes through the controller; more rejection, readiness and referral
  cases are covered, with exact assertions.
- #mapError no longer carries an unreachable signer branch; every
  caller classifies signer failures first.
- Status-entry rejections of an agent are covered for TWAP and batch
  cancels; the referral lock tests hold a real lock.
- Provider tests assert exact SDK writes, signatures, results and
  analytics instead of call counts; the builder fee signs its own
  payload.
- Lighter: the first readiness check and the keyring payload encoding
  are pinned; a stale skipped referral test is removed, with its
  suppressions.
…s, and serialize referral waiters

- A rejected agent the venue reports in cancel status entries fails
  scale, TP/SL and chase cancels with KEYRING_LOCKED, drops the agent
  and notifies the host, as a thrown rejection does.
- Providers waiting on another's referral attempt take the lock one at
  a time, so only one writes the referral.
- Lighter prepareTradingWallet: a read-only provider (no signer bridge)
  is ready at once without logging; no selected account returns
  NO_ACCOUNT_SELECTED; "User cancelled" declines are retried.
- TradingService batch logs keep the total failure count and add the
  reported count.
- onAgentRejected is documented as called per rejected write.
- Tests cover status-entry rejections, several waiters, address case,
  re-asks after drops, the Lighter retry and read-only paths, and move
  the controller binding cases to integration and AgentBindings tests.
…address

- A batch cancel whose status entries reject the agent reports it once
  per signed request.
- onAgentRejected receives the agent's address as the host supplied
  it, not the venue's lowercased form.
- prepareTradingWallet docs say which steps the main account signs and
  that the agent signs the referral and silent migration.
- LIGHTER_SIGNER_UNAVAILABLE_ERROR is module-private again.
- Tests give each fixture provider its own client service, pin exact
  calls and results, and cover the one-l "User canceled" decline.
…le, and drop agents rejected on retraction

- A host accountSigner that locks while signing and throws its own
  error now fails with KEYRING_LOCKED (the host error as its cause), so
  the referral and migration stay retryable (HyperLiquid and Lighter).
- Retracting a stale TWAP or an abandoned chase order drops a rejected
  agent and reports it, whether the rejection is thrown or in a status
  entry.
- The agent resolver no longer carries an unreachable supersede check.
- prepareTradingWallet docs on the HyperLiquid provider and the read-only
  Lighter wording match the controller's.
- Tests pin that a failing agent stays in use, two waiters at the
  referral lock, an unknown wallet without an address, and that every
  aggregated provider is prepared.
…ocks during builder fee setup

- prepareTradingWallet does not ask a wallet with no HyperLiquid account
  yet to approve the builder fee (the venue rejects its writes).
- A builder fee approval skipped because the signer cannot sign fails
  the write with KEYRING_LOCKED instead of its approval failure code, so
  a locked TP/SL update is not reported as an error.
- HyperLiquid prepareTradingWallet returns NO_ACCOUNT_SELECTED without
  logging, like Lighter.
- The controller's prepareTradingWallet docs say which declined steps
  are asked again.
- Test fakes wrap wallet errors as the SDK does; tests cover the
  retraction drops, a network switch with a resolved agent, the keyring
  signing phases and Lighter's recheck after a signed registration.
…report locked signers in every prepare path

- A wallet that starts trading setup before its first deposit gets its
  referral set once it has deposited, instead of after a reconnect.
- prepareTradingWallet reports KEYRING_LOCKED when the signer locked
  during an unfunded wallet's setup, and the controller reports it for
  a provider with nothing to prepare.
- A builder fee signature rejected as locked fails the TP/SL update or
  order with KEYRING_LOCKED instead of its approval failure code.
- The Lighter testnet rule the aggregated provider tags errors with
  lives in one internal helper.
- README documents signing without a KeyringController; the CHANGELOG
  names the results that changed.
- Tests run the KeyringController host shape through the controller,
  share the SDK error and signing fakes, and pin exact results.
…retry a referral whose code was not ready

- prepareTradingWallet returns EXCHANGE_ACCOUNT_NOT_FOUND for a wallet
  with no HyperLiquid or Lighter account yet, so the host can ask for a
  deposit instead of another attempt.
- A referral skipped because the referral code is not ready, or because
  the venue rejected the wallet as unknown, is attempted again.
- prepareTradingWallet reports a builder fee signature rejected as
  locked as KEYRING_LOCKED.
- Tests run the controller on a real host messenger (a stray keyring
  call throws), trade on testnet after a network switch, cover a host
  without onAgentRejected and the keyring readiness fallback, and pin
  the approval-failure and superseded-rejection paths.
…ion, and report a locked signer for providers without setup

- A HyperLiquid referral whose MetaMask referral code is not ready no longer
  holds trading setup back, so orders stop looking the code up again; the
  next prepareTradingWallet checks it. A failed lookup is logged once and
  waits for the next setup.
- PerpsController.prepareTradingWallet returns KEYRING_LOCKED when a provider
  reports ready while the main account cannot sign (for example an aggregated
  provider whose providers have nothing to prepare).
- Document every prepareTradingWallet result, use isProviderOnTestnet for the
  Lighter provider, and evict rejected agents directly where only eviction is
  needed.
- Split the HyperLiquid account-signer tests into focused files over a shared
  fixture, and cover a mixed batch cancel, a deselection during Lighter
  registration and more host error shapes.
…gent, and report stale or unselected preparation

- HyperLiquid cancel batches classify every status entry even when one
  reports a rejected agent, so orders the venue did cancel are no longer
  counted as resting. A TP/SL update that cancelled one leg restores it and
  fails with KEYRING_LOCKED; a scale ladder keeps only the rungs left.
- HyperLiquid prepareTradingWallet reports PROVIDER_LIFECYCLE_STALE when the
  provider disconnects during its account check or builder fee setup.
- PerpsController.prepareTradingWallet reports NO_ACCOUNT_SELECTED for a
  provider's ready result while no account is selected, and a read-only
  Lighter provider checks the selected account first.
- Keep each resolved agent next to its getAgentSigner answer in one map, and
  name the referral code lookup's logs after its method.
- Tests: TP/SL and scale mixed-status cancels, mid-setup disconnects, a host
  without onAgentRejected or whose onAgentRejected throws, distinct suite
  titles and shared main-account fixtures.
… TP/SL with a new agent, and check the prepared account

- HyperLiquid cancel batches read the per-entry statuses of the error the SDK
  throws when an entry fails before classifying the error, so orders the
  venue cancelled are no longer reported as resting or failed. cancelOrders
  gives KEYRING_LOCKED only to the entries that name the rejected agent.
- A TP/SL replacement the signer could not sign drops a rejected agent before
  the old protection is restored; a restoration the signer could not sign is
  not logged, and the protection is reported lost.
- HyperLiquid prepareTradingWallet reports PROVIDER_LIFECYCLE_STALE when the
  selected account changes during setup; the controller treats the empty
  account the AccountsController answers with nothing selected as
  NO_ACCOUNT_SELECTED.
- Tests: thrown and returned mixed cancel responses, TP/SL restoration with
  a new agent, the silent agent migration through the controller, a locked
  keyring host on Lighter, exact rejection identity, and fixture cleanups.
…stale, and treat unsigned HIP-3 transfers as retryable

- PerpsController.prepareTradingWallet compares the selected account before
  and after the provider's preparation (in aggregated mode, every provider in
  turn) and returns PROVIDER_LIFECYCLE_STALE when it changed. HyperLiquid's
  own check treats a deselection during setup as stale too.
- A HIP-3 rollback or auto-rebalance transfer that fails with KEYRING_LOCKED
  is debug-logged with the amount left on the HIP-3 DEX instead of reported
  as an error.
- #classifySignerFailure looks a rejected agent up once.
- Docs: the Fixed entry covers the HIP-3 transfers and no longer lists the
  new accountSigner; getAgentSigner's rejection wording.
- Tests: HIP-3 rollback and rebalance with KEYRING_LOCKED and failed
  transfers, a partial TP/SL cancel whose restoration fails, account changes
  during preparation, SDK-shaped thrown cancel errors (`cancel N: ...`),
  exact batch results and host callbacks, shared account and clock fixtures.
…keep the referral retry scoped to preparation, and keep main's unfunded-wallet referral behavior

- PerpsController.prepareTradingWallet compares the selected account before
  returning any provider result, ready or not.
- A pending builder referral code is checked again only by
  prepareTradingWallet: the setup flag is reset inside
  #ensureReadyForTrading right before the setup check, so a failure before it
  no longer leaves the referral for an order to sign.
- The referral no longer marks itself for retry when the wallet has no
  HyperLiquid account yet (main's behavior: the next provider attempts it).
- A HIP-3 pre-order collateral transfer the signer could not sign fails the
  order with KEYRING_LOCKED instead of a wrapped message that was logged.
- Debug logs record raw amounts; the setAgentSigner changelog entry says the
  messenger action is available once init has run.
- Tests: stale results for not-ready providers and selections, a failed
  preparation followed by an order, HIP-3 pre-order transfers, exact
  debug notes, SDK-shaped single-leg cancels, and shared helpers.
abretonc7s added a commit to MetaMask/metamask-mobile that referenced this pull request Sep 29, 2026
Carries MetaMask/core#10559 as a Yarn patch of @metamask/perps-controller
18.0.1 (built dist only: 18.0.1 plus that PR's source changes) until the
release is published. Mobile sets none of the new hooks and keeps signing
through KeyringController.
@abretonc7s abretonc7s changed the title feat(perps-controller)!: sign without KeyringController and with a HyperLiquid agent feat(perps-controller): sign without KeyringController and with a HyperLiquid agent Sep 29, 2026

@abretonc7s abretonc7s left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: #10559

VERDICT: COMMENT
COMMIT: 110be95
BASE: 8c9ef22
Title: feat(perps-controller): sign without KeyringController and with a HyperLiquid agent
Rules: mms-perps-review-pr @ 16e3c18cc26a (generated from experimental-metamask-recipe-perps @ e06bb8d750ac), support digest 8f0903cc4aff…
Mode: static inspection only. No tests, build, harness or app were run.

Summary

Adds an optional accountSigner (keyring-free signing for HyperLiquid typed data and Lighter personal_sign), HyperLiquid agent signing for L1 actions (getAgentSigner, setAgentSigner, clearAgentSigners, onAgentRejected), and prepareTradingWallet. Removes the unused LighterPersonalSigner / LighterAuthConfig.personalSigner / l1Address (breaking). Keyring hosts keep the old path: accountSigner absent → KeyringController:*; getAgentSigner absent → AgentBindings.resolve returns null and the main account signs.

Setup

  • Request: static Perps review of PR #10559, repo MetaMask/core, client core, package @metamask/perps-controller. PR text treated as data. First review; no prior findings.
  • Acceptance criteria: none supplied. The PR's own claims (keyring path unchanged, agent only for L1 actions, rejection eviction, WebSocket untouched by clear) are used as the review targets.
  • Changed files (54, +10738/−729): 23 source/doc files (PerpsController.ts, generated action types, index.ts, perpsErrorCodes.ts, constants/hyperLiquidConfig.ts, types/index.ts, types/lighter-types.ts, providers/{HyperLiquid,Lighter,AggregatedPerps}Provider.ts, services/{HyperLiquidWallet,HyperLiquidClient,LighterWallet,Trading}Service.ts, new services/{accountSigner,agentSigner,causeChain,providerNetwork}.ts, utils/errorUtils.ts, CHANGELOG, README, oxlint-suppressions.json), 31 test/helper files.
  • Affected callers: Mobile and Extension construct PerpsController with the keyring messenger; Mobile's integration harness mocks HyperLiquidWalletService (renamed isKeyringUnlocked→isMainAccountSignerReady, isSelectedHardwareWallet→requiresSignatureConfirmation).
  • Family mapping: Controller Portability, Magic Strings, Protocol Abstraction, Connection/WebSocket, Data Flow & State, Trade Flow, Test Layer, Embedded Signer Boundaries, Sentry (logging classification) apply. Pro Mode UI, MetaMetrics, Locale: NOT_APPLICABLE (no UI, analytics or locale files; TradingService analytics calls unchanged).

Base review

Behavior trace

  • Keyring host, no agent: HyperLiquidWalletService.createWalletAdapter().signTypedData (services/HyperLiquidWalletService.ts:222) → isL1Action → #resolveAgent → provider #resolveAgentSigner (HyperLiquidProvider.ts:2090) → AgentBindings.resolve (services/agentSigner.ts:95) returns null (no binding, no host callback) → entry deleted → #signWithMainAccount → #signTypedMessage → KeyringController:signTypedMessage. Same as base apart from one extra async hop.
  • accountSigner host: #signWithMainAccount checks isReady() before and after a throw, maps to KEYRING_LOCKED (HyperLiquidWalletService.ts:176-198); Lighter mirrors it (LighterWalletService.ts:95-114). The signing address still comes from the selected account.
  • Agent host: L1 payloads (primaryType === 'Agent' and domain Exchange) go to the agent; user-signed payloads (HyperliquidTransaction:*, including ApproveAgent) never reach it, so an agent cannot approve itself or sign withdrawals. Real-SDK routing and signer recovery are covered in HyperLiquidWalletService.account-signer.test.ts:402-510 (Node ≥24.9 only).
  • Agent cache: keyed network:mainAddressLower (agentSigner.ts:38). A pending answer that spans clearAgentSigners is discarded via a generation counter and asked again (HyperLiquidProvider.ts:2111-2135). Null and failures aren't cached.
  • Rejection: #findRejectedAgent needs isHyperLiquidUserNotFoundError plus the reported address matching an agent this provider recorded in #agentSignedFor. A main account with no venue account never matches, so the unfunded-wallet path (EXCHANGE_ACCOUNT_NOT_FOUND) keeps its base behavior. On eviction: the cache entry is dropped if it still matches, the controller releases a matching binding, and the host is told through onAgentRejected, with a throwing callback contained.
  • Error classification: every changed write catch calls #handleSignerFailure before logging, and TradingService skips logger.error for KEYRING_LOCKED results. Batch cancels now read per-entry statuses from thrown SDK errors (HyperLiquidProvider.ts:9700-9750), and entries the venue did cancel stay successful.
  • TP/SL update: the cancel moved before the clear/replace branch (:10731-10766) so both use one batch result. A signer failure with nothing cancelled returns KEYRING_LOCKED without placing. A partial cancel restores the old protection and returns the surviving IDs.
  • prepareTradingWallet: HL checks readiness first, captures lifecycle generation + address, runs #ensureReadyForTrading(recheckPendingReferral), checks registration, runs builder fee with reportSignerFailure, and rechecks provider/account currency after each await (:14668-14756). The controller adds its own account-change and signer checks (PerpsController.ts:5959-5998). The aggregated provider runs providers sequentially and returns the first not-ready result (AggregatedPerpsProvider.ts:1064).
  • Retry vs permanent caches: signer failures, including a rejected agent, set #unifiedAccountSetupNeedsRetry / #referralSetupNeedsRetry and cache nothing. A referral code in pending sets #referralAwaitsBuilderCode, which only prepareTradingWallet re-checks. The cross-provider referral lock now loops until it is free and re-checks the cache under the lock (:15877-15914).
  • #ensureBuilderFeeApproval now rethrows KEYRING_LOCKED instead of returning. Its only caller, #ensureBuilderFeeSetup (:2966), catches it: the order path maps it to KEYRING_LOCKED and the non-blocking path swallows it after a debug log. No other caller exists.

Tests (inspected, not executed)

  • Inspected: PerpsController.agent-signing.integration.test.ts (15 cases: routing, keyring host, pins, re-creation, pre-init binding, rejection + release, prepare), HyperLiquidWalletService.account-signer.test.ts (real SDK signL1Action/signUserSignedAction + signer recovery), and the case counts of HyperLiquidProvider.agent-rejection (25), prepare-trading-wallet (28), LighterProvider.account-signer (18) and agentSigner (8).
  • PR claims 4,009 passing on Node 24 and 4,005 on Node 22, and says main's suite passes against this source apart from the 2 removed-signer tests. Not verified here. The real-SDK suite is skipped below Node 24.9, so the Node 22 CI job does not exercise the EIP712Domain/signer-recovery path.

Permissions, secrets, dependencies, wiring

  • No new dependencies. Core never receives agent key material, only a signTypedData interface, and logs carry addresses, primaryType and domain but no payload secrets.
  • setAgentSigner is a messenger action that takes a function-bearing signer. A bound agent that isn't approved for the account fails at the venue, and one approved for another account trades only that account. No privilege escalation found.
  • No flags, localization or analytics changes.

Signal over noise

  • No TODOs, debug console, ticket keys or tool mentions in source. oxlint-suppressions.json drops two suppressions for a rewritten test, which is correct.
  • Nit: the JSDoc and inline comments are long and repeated across layers. The prepareTradingWallet contract appears nearly verbatim in PerpsController.ts (~30 lines), the generated action types, PerpsProvider in types/index.ts:2162, HyperLiquidProvider.ts and LighterProvider.ts. The comment on #agentSignedFor is 9 lines. See finding F1.

Perps criteria ledger

Family Rule Outcome Evidence
Controller Portability Platform import in controller PASS New files import only package-local modules and @metamask/utils; grep of new services for process./window./react-native found nothing
Controller Portability Deep import from app code NOT_APPLICABLE Core-only diff
Controller Portability __DEV__/platform globals PASS None added
Controller Portability New dependency not in DI interface PASS Signing added via PerpsPlatformDependencies.accountSigner and providerCredentials.hyperliquid (types/index.ts:1118-1147, 2916-2922)
Controller Portability Breaking the publisher contract PASS Additive API. Breaking removal of LighterPersonalSigner is documented; HyperLiquidWalletService renames are internal but mocked by Mobile (PR note). See core overlay
Magic Strings Hardcoded strings/numbers PASS L1 EIP-712 shape exported as HYPERLIQUID_L1_ACTION_PRIMARY_TYPE/_DOMAIN_NAME (constants/hyperLiquidConfig.ts:185-195). 4001 and the regex in LighterProvider.ts:1008-1013 are named constants. README default slot 7 matches LIGHTER_DEFAULT_API_KEY_INDEX (constants/lighterConfig.ts:198). Placeholder/timeout/slippage/leverage/precision/URL/cache rules NOT_APPLICABLE
Protocol Abstraction Execution identity from display fields NOT_APPLICABLE No order identity changes
Protocol Abstraction Provider identity lost NOT_APPLICABLE No fill aggregation changes
Protocol Abstraction Hardcoded provider / provider-specific error handling PASS prepareTradingWallet/clearAgentSigners are optional PerpsProvider members. Aggregated provider fans out and tags errors per provider (AggregatedPerpsProvider.ts:1064-1101). The agent is HL-specific and lives in HyperLiquidCredentials
Protocol Abstraction UI branching, symbols, decimals, detailedOrderType NOT_APPLICABLE No UI/market changes
Pro Mode UI Gating all NOT_APPLICABLE No UI
MetaMetrics all NOT_APPLICABLE No event changes
Sentry Tracing Unbounded background trace volume PASS No new traces. Signer failures move from logger.error to debugLogger (less Sentry volume). New logger.error sites are unexpected failures only: prepareTradingWallet catch-alls and the aggregated throw
Connection & WebSocket Cleanup owner for in-flight setup PASS prepareTradingWallet captures #lifecycleGeneration before awaits and asserts after (HyperLiquidProvider.ts:14677-14719). Lighter maps LighterSessionCancelledError to PROVIDER_LIFECYCLE_STALE
Connection & WebSocket Stale data after async gap PASS Address re-read after each await (assertPreparationCurrent, controller addressAtStart compare). The agent is resolved per signature from the fresh selected account
Connection & WebSocket Second lifecycle owner / WS leak PASS clearAgentSigners only clears maps (HyperLiquidProvider.ts:14643-14646); no connect/disconnect
Connection & WebSocket mobile-hook rules (throttle, per-component WS, cache invalidation, WebView) NOT_APPLICABLE Core-only
Data Flow & State Changed classification leaves old priority rules PASS KEYRING_LOCKED widened to agent failures (documented in perpsErrorCodes.ts:97-99). TradingService suppression is keyed on the same code, and batch results keep per-entry errors
Data Flow & State Derived flag lifecycle PASS #referralSetupNeedsRetry/#referralAwaitsBuilderCode reset at the start of each #ensureReferralSet and set only on their paths
Data Flow & State Async flow loses cleanup ownership PASS Referral lock: every return path calls completeInFlight(). Builder-fee promise map cleaned in finally
Data Flow & State client hook/UI rules NOT_APPLICABLE Core-only
Trade Flow Signed bounds collapsed NOT_APPLICABLE No RoE/price math changed
Trade Flow Pre-trade checks / post-trade refresh / slippage PASS Order path unchanged apart from error classification. TP/SL cancel-before-place ordering kept, and a signer failure never places replacements over live protection
Locale all NOT_APPLICABLE No locale files
Test Layer Degenerate fixtures PASS Distinct main vs agent keys with signer recovery. Rejection tests use a reported address differing in case from the supplied one (HyperLiquidProvider.agent-rejection.test.ts:1106)
Test Layer Assertions miss behavior PASS Integration test asserts which key signed each payload, not only call counts
Test Layer Clock/numeric control NOT_APPLICABLE
Embedded Signer Boundaries Key material after boundary PASS Core receives no key material; the agent key stays host-side behind PerpsAgentSigner
Embedded Signer Boundaries Navigation policy / bridge messages NOT_APPLICABLE No WebView/bridge changes; Lighter signer bridge untouched

Cross-repository conformity

  • Parity (references/parity.md): NOT_APPLICABLE. No screens, hooks or formatters changed.
  • Shared package surface: NOT_CHECKED for Extension. No Extension or Mobile checkout at a recorded revision was supplied, so public import compatibility was not inspected. Mobile compatibility rests on the PR's claim (MetaMask/metamask-mobile#36966 Yarn patch; mock renames). This review did not verify it.

Core criteria

Rule Item Outcome Evidence
Public Controller Contracts State shape changes without client migration PASS No PerpsControllerState change
Public Controller Contracts Method signature changes without compatibility plan PASS New methods/actions only (setAgentSigner, clearAgentSigners, prepareTradingWallet, PerpsController.ts:5896-5998). Optional PerpsProvider members. The internal HyperLiquidWalletService renames need Mobile's mock update, which the PR states was done in metamask-mobile#36966 (not verified)
Public Controller Contracts Event name/payload drift PASS No events changed
Public Controller Contracts Package export changes without package-level tests FINDING F1 New exports (index.ts:74,131,147,337-340,467-468) and the removal of LighterPersonalSigner have no consumer-style assertion through src/index.ts. Tests import from internal paths (tests/helpers/agentFixtures.ts:7, integration test lines 11-24). The only index import is tests/placeholder.test.ts
Release Metadata Public API change without changelog PASS CHANGELOG Unreleased: Added/Removed/Fixed in the required order, with the BREAKING entry under Removed and migration guidance (CHANGELOG.md:15-50)
Release Metadata Breaking change released as minor/patch PASS No version bump in this PR (package.json 18.0.1). The release PR must ship this as 19.0.0
Release Metadata Controller and client integration out of sync NOT_CHECKED Mobile paired PR linked (#36966). No Extension compatibility note, and no Extension checkout supplied
HL Multi-Sig Probe + catch classifier per user-scoped write NOT_APPLICABLE No new exchange write. prepareTradingWallet drives the existing migration, referral and builder-fee writes and keeps their #isWalletOnHyperliquid gates (HyperLiquidProvider.ts:14700-14714, :15851-15862). Agent routing does not change which write is sent
#ensureUnifiedAccountEnabled cache semantics Permanent condition cached as retryable / both flags / deferred treated as failure PASS Only transient signer failures (locked, agent unavailable, agent rejected → next resolve gets a new agent) set #unifiedAccountSetupNeedsRetry without caching (HyperLiquidProvider.ts:2697-2708). Permanent and defer paths are unchanged
Metered Benefits Strict improvement NOT_APPLICABLE No fee-resolution change
Read-only Account Guards Every write guarded PASS Lighter read-only (no signer bridge) prepareTradingWallet returns without writing (LighterProvider.ts:1339-1341). HL has no read-only account mode. The agent can't sign user-signed actions (withdraw, transfers, approveAgent), enforced in isL1Action (HyperLiquidWalletService.ts:54-59)
Evidence Expected Contract matrix, client note, abstraction tests, grep for client imports PARTIAL / NOT_CHECKED PR lists every new API and links the Mobile PR, and provider/aggregated tests exist. No Extension note or explicit grep evidence in the PR, so this review ran its own grep of the new files (no client globals)

Findings

  • F1 (minor) packages/perps-controller/src/index.ts:338: The public surface changes (new PerpsAccountSigner, PerpsAgentSigner, PerpsAgentAccount, PerpsTypedDataPayload, three action types, HYPERLIQUID_L1_ACTION_*, removed LighterPersonalSigner), but no package-level test imports them from src/index.ts. A dropped or misspelled export would pass every internal-path test, and the PR's own harness note shows consumers depend on PerpsAccountSigner being exported. Smallest fix: one consumer-style test that imports the new runtime constants and types from ../src/index.js and asserts the constant values, with type-only assignments for the signer shapes.
  • F2 (nit) packages/perps-controller/src/PerpsController.ts:5959: The prepareTradingWallet contract is written out at length (~30 lines) and repeated nearly verbatim in the generated action type, PerpsProvider.prepareTradingWallet (types/index.ts:2162-2179), HyperLiquidProvider.prepareTradingWallet and LighterProvider.prepareTradingWallet. Similar long comments sit on #agentSignedFor (HyperLiquidProvider.ts:1570-1580) and getAgentSigner (types/index.ts:1118-1133). Keep the full contract once, on the PerpsProvider type or the controller method, and cut the others to a line or two plus a reference, so the copies can't drift.

No blocker or major defects found in the traced paths.

Evidence

  • Frozen diff 8c9ef2233...110be95a9 read for every non-test source file. Key paths traced with file:line above.
  • Tests inspected, not executed: the agent-signing integration suite, real-SDK wallet-adapter suite, agent-rejection case at :1106, and the case counts of the prepare/Lighter/agentSigner suites.
  • PR-reported runtime evidence (testnet harness 5/5 across the keyring, accountSigner and agent modes, agent lifecycle, revocation → KEYRING_LOCKED + onAgentRejected, Mobile patch run) is recorded as claims only. This review did not reproduce any of it.

Limitations

  • Static only. No tests, type-check, lint, yarn changelog:validate or build were run, so the test counts and coverage claims are unverified.
  • @nktkas/hyperliquid is not installed in this checkout, so the SDK's error-message format and EIP712Domain inclusion were checked only through the repo's tests, not the SDK source. The real-SDK suite is skipped below Node 24.9.
  • Extension and Mobile checkouts were not supplied: consumer compatibility (the renamed internal wallet-service methods in mocks, the removed Lighter type) is NOT_CHECKED.
  • The PR itself notes one gap: whether a wallet will sign an approveAgent domain chainId other than its active network was not tested.

Run QA handoff (questions)

  1. Run yarn workspace @metamask/perps-controller run test on Node 22 and on Node ≥24.9. Confirm the real-SDK suite runs on 24.9+ and that coverage thresholds hold.
  2. Run yarn changelog:validate and yarn lint (constraints, knip) for the new exports and the removed type.
  3. Build Extension against this package and check for LighterPersonalSigner imports and HyperLiquidWalletService method mocks.
  4. On testnet with an agent host, with a revoked agent during an active chase order: confirm each tick calls onAgentRejected and the host deduplicates prompts as documented.

Recommended Action

COMMENT. The implementation is careful: the keyring path is unchanged, agents are confined to L1 actions and to the account and network they were resolved for, signer failures are retryable without cache poisoning, and lifecycle and account races are guarded. Before approval: add the package-level export test (F1), optionally trim the duplicated docs (F2), and close the NOT_CHECKED Extension compatibility and unexecuted-test gaps with a Run QA pass.

Comment thread packages/perps-controller/src/index.ts
Comment thread packages/perps-controller/src/PerpsController.ts

@geositta geositta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. Injected signer contracts keep host owned keys separate from provider trading logic. HL routes L1 actions through account and network scoped agents while retaining main account signing for user authorized actions.

@abretonc7s
abretonc7s added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit f5c5c09 Sep 29, 2026
344 checks passed
@abretonc7s
abretonc7s deleted the feat/perps-account-signer branch September 29, 2026 23:14
@abretonc7s abretonc7s mentioned this pull request Sep 30, 2026
3 of 4 tasks
github-merge-queue Bot pushed a commit that referenced this pull request Sep 30, 2026
## Explanation

- Release major perps-controller 19.0.0

Breaking changes (see changelog):
- `OrderFill.liquidation.liquidatedUser` is now optional
- `FrontendOrder.orderType` union adds `Twap Slice`, `Vault Close`,
`Spot Dust Conversion`
- Removed `LighterPersonalSigner` and the `personalSigner` / `l1Address`
fields of `LighterAuthConfig`

## References

- #10414, #10437, #10464, #10559, #10588, #10589, #10591

## Checklist

- [x] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, Markdown, etc.) for new or
updated code as appropriate
- [x] I've communicated my changes to consumers by [updating changelogs
for packages I've
changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md)
- [ ] I've introduced [breaking
changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md)
in this PR and have prepared draft pull requests for clients and
consumer packages to resolve them
pull Bot pushed a commit to Reality2byte/metamask-mobile that referenced this pull request Sep 30, 2026
…36980)

<!--
Please submit this PR as a draft initially.

Do not mark it as "Ready for review" until this PR meets the canonical
Definition of Ready For Review in `docs/readme/ready-for-review.md`.

In short: the template must be materially complete (not just section
titles
present), all status checks must be currently passing, and the only
expected
follow-up commits must be reviewer-driven.
-->
<!--
mms-check directive vocabulary — read by
.github/scripts/shared/pr-template-checks.ts
at module load to build the validation plan. Directives are invisible in
rendered
markdown and must NOT be removed or edited without updating the
validator registry.

  type=text           Section must contain non-placeholder prose.
  type=changelog      Section must have a valid CHANGELOG entry: line.
type=issue-link Section must have a Fixes:/Closes:/Refs: line with a
value.
type=manual-testing Section must have real testing steps or an explicit
N/A.
type=screenshot Section must have evidence (image/URL) or an explicit
N/A.
type=checklist Section must have all checkboxes consciously checked.
required=true|false Whether a missing/invalid section runs the validator
at all.
blocking=true|false Whether a failure of this check fails the CI
workflow.
Default: false — failures are shown as warnings in the sticky
                      comment but do not block the PR.

Sections without a directive are checked for structural presence only.
-->

## **Description**

<!-- mms-check: type=text required=true -->

Lighter orders are signed by a trading key (Lighter's "API key") that
the embedded signer registers in a numbered slot of the account, with
one `personal_sign` from the wallet. A key only works on the device that
created it, so simulators, devices and scripts sharing an account need
different slots. When the configured slot already holds another device's
key, the app stops with "Lighter API key slot N already contains a
different key", which is hard to understand without context.

This adds `docs/perps/lighter-api-keys.md` (what a slot is, local setup,
how to check a free slot, the error and its fix, testnet resets) and
points the `MM_PERPS_LIGHTER_API_KEY_INDEX` comment in `.js.env.example`
to it. The perps-controller README gets the same explanation in
MetaMask/core#10559.

## **Changelog**

<!-- mms-check: type=changelog required=true blocking=true -->

CHANGELOG entry: null

## **Related issues**

<!-- mms-check: type=issue-link required=true -->

Refs: MetaMask/core#10559

## **Manual testing steps**

<!-- mms-check: type=manual-testing required=true -->

N/A: documentation only. The fix it describes was checked on an iOS
simulator against Lighter testnet: with the configured slot held by
another device's key the app stopped with the error above, and with
`MM_PERPS_LIGHTER_API_KEY_INDEX` set to a free slot the app registered
its own key there.

## **Screenshots/Recordings**

<!-- mms-check: type=screenshot required=true -->

N/A: documentation only.

## **Pre-merge author checklist**

<!-- mms-check: type=checklist required=true -->

<!--
Every checklist item must be consciously assessed before marking this PR
as
"Ready for review". A checked box means you deliberately considered that
responsibility, not that you literally performed every action listed.

Unchecked boxes are ambiguous: they are not an implicit "N/A" and they
are not
a silent "skip". See `docs/readme/ready-for-review.md` for the full
checklist
semantics.
-->

- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I've included tests if applicable
- [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

#### Performance checks (if applicable)

- [ ] I've tested on Android
  - Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example

For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).

## **Pre-merge reviewer checklist**

<!--
Reviewer checklist items follow the same semantics as the author
checklist: an
unchecked box is ambiguous, a checked box means the reviewer consciously
assessed that responsibility. See `docs/readme/ready-for-review.md`.
-->

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Documentation and env example comments only; no runtime or security
logic changes.
> 
> **Overview**
> Adds **`docs/perps/lighter-api-keys.md`** so developers understand
Lighter **trading key slots**: how signing works vs the wallet, local
env setup (`MM_PERPS_LIGHTER_PROVIDER_ENABLED`,
`MM_PERPS_LIGHTER_API_KEY_INDEX`, account index), **one slot per
device/simulator/script**, curl checks for free slots, what to do when
**"Lighter API key slot N already contains a different key"** appears,
and account index behavior after testnet resets.
> 
> Updates the **`MM_PERPS_LIGHTER_API_KEY_INDEX`** comment in
**`.js.env.example`** to call it a Lighter trading key slot, note the
controller default (**7**), and link to the new doc.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ec3ad2a. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants