Skip to content

Add Stark key registration tool for USER_UNREGISTERED withdrawals - #22

Merged
ermyas merged 3 commits into
mainfrom
docs-eth-starkkey-registration
Sep 24, 2026
Merged

ermyas merged 3 commits into
mainfrom
docs-eth-starkkey-registration

Conversation

@ermyas

@ermyas ermyas commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Users whose Stark key is not the decimal form of their Ethereum address were registered off-chain by Immutable X. The bridge has no ethKey on record for them, so withdraw() reverts with USER_UNREGISTERED even though getWithdrawalBalance shows funds. Registration (registerSender / registerEthAddress, live since StarkExchangeMigrationV2) needs a Stark-curve signature from the Stark private key. Immutable X derived that key from a wallet signature, so users cannot produce it through Etherscan.

This PR adds tools/stark-key-registration, a local web page for Ethereum Mainnet that:

  1. asks the wallet to sign the Immutable X key message and re-derives the Stark key in the browser;
  2. checks the bridge for pending withdrawals of each token in config/operate/mainnet/imx_tokens.json (getWithdrawalBalance needs an asset ID, so each token is queried);
  3. signs the registration payload bound to the connected address and submits registerSender;
  4. finalises each pending withdrawal once the key is registered to the connected wallet.

It also updates docs/finalise-pending-withdrawals.md with:

  • a getEthKey check step;
  • a USER_UNREGISTERED section pointing to the tool;
  • the registration gas cost;
  • a troubleshooting table.

Key derivation

src/lib/derivation.ts reimplements generateLegacyStarkPrivateKey from @imtbl/core-sdk 3.6.1. For about 1 in 30 wallets the first grinding round is rejected, and the SDK's three grinding variants then disagree. The SDK chose between them by querying the Immutable X API (/v1/users), which now returns 404, so the SDK fails for those users. The tool computes every candidate and uses the one holding funds on the bridge. Every candidate derives from the wallet's own signature, so a derivation error yields a key with no funds, never another user's key.

Safeguards

  • Funds go only to the connected wallet. registerSender uses msg.sender as the owner, and the page withdraws only for keys registered to the connected wallet.
  • Keys and signatures stay in the tab. The Stark private key exists only inside deriveAccounts. The CSP sets connect-src 'none', and chain access goes through the wallet extension.
  • Environment checks. The page refuses to run unless served from 127.0.0.1/localhost. It checks for chain 1 and implementation 0x273b65a7231321D4ee47a4c47408Ef43517455Ec.
  • Simulation. Every transaction is simulated with eth_call before it is sent.
  • Dependencies. Versions are pinned. npm ci --omit=dev installs vite plus four runtime libraries (ethers, @scure/bip32, @scure/starknet, @noble/hashes), and npm audit is clean for the full tree. @imtbl/core-sdk is not a dependency: its outputs are recorded as test vectors by test/vectors/generate-sdk-vectors.ts, which installs it with npm i --no-save. Socket flagged the SDK's axios@0.26.1 and ws@8.18.0; those alerts are resolved by this change.
  • Disclaimers. The page, the tool README and the guide all state that the tool is unaudited and provided as-is, and give anti-phishing guidance.

Registration gas

registerSender uses about 7.9M gas, because StarkCurveECDSA.verify does elliptic-curve arithmetic in Solidity. That is about 0.0006 ETH at 0.07 gwei and 0.04 ETH at 5 gwei. It fits within the 60M block limit and the 16.7M per-transaction cap. With too little balance or gas limit, wallets and simulators report the transaction as likely to fail, and the trace stops at the modexp precompile. The docs describe this.

Testing override

?allow-registration-without-funds offers registration for unregistered keys with no pending withdrawals, so registration can be tested end to end on mainnet with a test wallet. It is not linked from the page, shows a testing-mode banner when set, and is documented in the tool README's Development section.

Test plan

  • npm run typecheck
  • npm audit: 0 vulnerabilities.
  • npm test: 12 unit tests.
    • Derivation equals the outputs of @imtbl/core-sdk 3.6.1 recorded in test/vectors/sdk-3.6.1.json. This covers typical wallets, and the exact candidate set for ambiguous wallets across every API-resolved branch, including a three-candidate wallet. Public keys match the SDK's Stark signer.
    • Registration signatures pass a BigInt port of the contract's StarkCurveECDSA.verify, including its r/s⁻¹ bounds, and fail for another address.
  • ETH_RPC_URL=… npm run test:fork: 9 tests against the deployed bridge on an anvil fork.
    • Register and withdraw ETH and USDC.
    • Recover funds held by a non-primary candidate.
    • Signature replay from another wallet is rejected.
    • A key registered elsewhere is refused.
    • No withdrawal before registration.
    • Changed-implementation and non-mainnet detection.
    • The account from the originating support ticket (0.45 ETH, unregistered) reads correctly.
  • ETH_RPC_URL=… npm run test:e2e: 7 Playwright tests of the built page on a fork, using a stub wallet that follows MetaMask's event ordering and chain ID validation.
    • Full flow and the no-funds path.
    • Switching from another chain.
    • Wallet events before and after connecting.
    • RPC failure and retry.
    • The testing override.
    • The block on hosted copies.
    • No requests beyond the page's own files, and no CSP violations.
  • Mutation checks: altering a grinding variant, the registration prefix, the switch chain ID or the wallet-event handling makes the corresponding suite fail.
  • Manual run with MetaMask against a mainnet fork: connect, network switch, sign, derive, balance reads, and the registerSender prompt. The issues found in this run are fixed in 9bfe72a.
  • The registerSender calldata MetaMask produced, run with eth_call on real mainnet from the signing address, succeeds. The same calldata from another address reverts INVALID_STARK_SIGNATURE.
  • Confirmed registration on mainnet with a funded test wallet.
  • Manual run with Rabby.

🤖 Generated with Claude Code

Users whose Stark key is not the decimal form of their Ethereum address
were registered off-chain by Immutable X, so the bridge has no ethKey on
record and withdraw() reverts with USER_UNREGISTERED. Registering needs a
Stark-curve signature from the Stark private key, which Immutable X
derived from a wallet signature, so users cannot do it through Etherscan.

tools/stark-key-registration is a local web page that:
- asks the wallet to sign the Immutable X key message and re-derives the
  Stark key in the browser;
- signs the registration payload bound to the connected address and
  submits registerSender, so funds can only go to the signing wallet;
- finalises each pending withdrawal once the key is registered to the
  connected wallet.

The derivation reimplements generateLegacyStarkPrivateKey from
@imtbl/core-sdk 3.6.1. For about 1 in 30 wallets the SDK disambiguated
between three grinding variants by querying the Immutable X API, which
has been retired; the tool computes every candidate and uses the one
holding funds on the bridge.

Safeguards: CSP with connect-src 'none', refusal to run off localhost,
chain and implementation checks, eth_call simulation before every
transaction, and pinned dependencies with a four-library runtime bundle.

Tests: unit equivalence with the SDK (including nock-driven fallback
branches), signature checks against the SDK's elliptic curve, mainnet
fork tests against the deployed bridge, and a Playwright run of the built
page on a fork.

docs/finalise-pending-withdrawals.md gains a getEthKey check step, a
USER_UNREGISTERED section pointing to the tool, and a troubleshooting
table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@socket-security

socket-security Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

All alerts resolved. Learn more about Socket for GitHub.

This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored.

View full report

ermyas and others added 2 commits September 23, 2026 17:18
Socket flagged ten axios advisories and one ws advisory reached through
@imtbl/core-sdk@3.6.1 (axios 0.26.1, ws 8.18.0), and obfuscated-code
warnings for nock and @mswjs/interceptors. All were development
dependencies used only as test references and absent from the page
bundle, but the SDK pins axios 0.26.1 and cannot be upgraded.

- Record the SDK's outputs once in test/vectors/sdk-3.6.1.json.
  generate-sdk-vectors.ts drives generateLegacyStarkPrivateKey with nock
  answering each API-resolved branch, and runs with both packages
  installed via `npm i --no-save`, so neither enters the lockfile. The
  derivation test now compares against these vectors: single-candidate
  wallets, the exact candidate set for ambiguous wallets (including a
  three-candidate wallet), and the SDK signer's public keys.
- Replace elliptic and hash.js in the registration test with a BigInt
  port of StarkCurveECDSA.verify, which checks signatures by the
  on-chain rule and clears elliptic's GHSA-848j-6mx2-7j84 advisory.
- Store test wallets in the vectors as indices rather than private keys.

npm audit reports no vulnerabilities for the full tree. Unit (12), fork
(9) and end-to-end (3) suites pass, and mutating the grinding indices or
the registration prefix still fails them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ion tool

Manual runs with MetaMask surfaced several problems the stub-wallet tests
did not catch.

- The network switch sent chainId 0x01; MetaMask requires unpadded hex
  and rejected it, and the page swallowed the error. Send 0x1, report
  failures, and reconnect after a successful switch.
- Unlocking MetaMask and approving the site emits accountsChanged, and
  the page reloaded on any wallet event, discarding the acknowledgements.
  MetaMask also emits chainChanged after resolving the switch request,
  which reloaded the page once reconnected. Ignore wallet events before
  a connection exists, and afterwards reload only when the account
  changes or the wallet leaves mainnet. Guard against concurrent
  connection attempts.
- Show the switch button only for a wrong network, with a note that
  MetaMask can keep a separate network per site.
- A failing wallet RPC surfaced as ethers' "missing revert data". Explain
  that the bridge could not be read, show the endpoint's error, and offer
  a retry. Read balances five at a time, since wallet endpoints throttle
  bursts of parallel eth_calls.
- Label the page "Ethereum Mainnet only" and list the tokens checked for
  pending withdrawals, since getWithdrawalBalance needs an asset ID and
  tokens outside config/operate/mainnet/imx_tokens.json are not found.
- Add ?allow-registration-without-funds, which offers registration for
  unregistered keys with no pending withdrawals so registration can be
  tested on mainnet. The page shows a testing-mode banner when it is set.
- Stop emitting source maps (DevTools fetches are blocked by the page's
  connect-src 'none') and add an inline favicon.

The stub wallet in the end-to-end tests now follows MetaMask's event
ordering and chain ID validation, and covers each of the above.

Docs: registration uses about 8 million gas because the bridge verifies
the Stark signature in Solidity. State this, with costs at several gas
prices, in the guide and the tool README, and explain the out-of-gas
trace a simulator shows when the gas limit or balance is too low.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ermyas
ermyas marked this pull request as ready for review September 24, 2026 02:00
@ermyas
ermyas requested a review from a team as a code owner September 24, 2026 02:00
@ermyas
ermyas merged commit a073a60 into main Sep 24, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants