Skip to content

Import a Sphere wallet from a recovery phrase or JSON export - #6

Open
KruGoL wants to merge 10 commits into
mainfrom
feat/sphere-wallet-import
Open

Import a Sphere wallet from a recovery phrase or JSON export#6
KruGoL wants to merge 10 commits into
mainfrom
feat/sphere-wallet-import

Conversation

@KruGoL

@KruGoL KruGoL commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes #5.

Lets a wallet created in Sphere be loaded here — by pasting the 12/24-word recovery phrase or by uploading Sphere's JSON export — and land on exactly the address Sphere shows.

The phrase matters more than the JSON: in unicity-sphere/sphere-sdk#696 the user has only the 12 words, because Sphere presents them as the recovery method. Until now this wallet had no BIP39 support at all.

The derivation was already right

This wallet's BIP32 code needed no changes. It reproduces sphere-sdk's own test vectors and a real Sphere export byte for byte:

  • Sphere derives alpha1 addresses at m/44'/0'/0'/0/{i}DEFAULT_BASE_PATH + /chain/index, bech32 witness v0, hrp alpha. It writes that path into its export as descriptorPath: "44'/0'/0'", which is this wallet's own field name and format.
  • deriveAddressAtIndex() fed that path produces the same public key Sphere recorded, and createBech32() reproduces addresses generated by the SDK's publicKeyToAddress.

The first test in the suite locks that in: it runs the existing derivation against the vectors from sphere-sdk/tests/integration/wallet-derivation.test.ts (wallets documented there as "exported from webwallet") and fails if it ever drifts.

What was missing

  • BIP39. Implemented on the already-bundled CryptoJS — canonical 2048-word list (its sha256 is asserted in the tests), NFKD normalisation, checksum validation, PBKDF2-HMAC-SHA512 2048 rounds, HMAC-SHA512("Bitcoin seed"). No new dependencies.
  • Sphere's JSON export. Encrypted exports use CryptoJS.AES.encrypt(value, password) directly, not the PBKDF2 scheme this wallet uses for its own .txt backups, so it needs its own decrypt path.
  • descriptorPath was never persisted. generateNewAddress() reads it and falls back to m/84'/1'/0' when it is absent. Every path below that leaked it, which is how one wallet ends up with addresses on two different branches.

Bugs found and fixed along the way

  1. .json was routed into the wallet.dat/text parser. The file-input change handler only knew .txt and .dat; anything else fell into the automatic discovery flow, which hides the Load button and feeds the file to extractFromWalletDat() → "Could not extract master key from file".
  2. performWalletScan() dropped descriptorPath when rebuilding extractedWalletData, so the scan walked the 84'/1'/0' fallback instead of the wallet's branch. Harmless for .dat wallets by coincidence, wrong for anything else. Extracted as mergeScanWalletData() and covered by tests.
  3. selectWalletForImport() built the wallet without descriptorPath, losing the branch again after the user picked a scanned address.
  4. Text backups never carried the path, and never read it. saveWallet() did not write a DESCRIPTOR PATH: line, and the string appeared nowhere in the file — so even Sphere's own exportToTxt, which does write it, was imported onto the wrong branch. Import → save .txt → load that .txt produced a different address.

For backups already saved without the line, resolveDescriptorPathFromBackup() recovers the branch by deriving each candidate and matching the address the file itself records — an exact check against the file, not a guess. When nothing matches it returns null and the previous default stands.

Safety

  • JSON imports are checked against an anchor. The export records addresses[0].publicKey; the derived key is compared against it and the import is refused on mismatch, rather than silently showing a different address.
  • Phrase imports pin 44'/0'/0'. A phrase carries no address to check against, so the path is fixed to Sphere's — any other choice would show a different address than Sphere does for the same words.
  • After import the index is scanned on the wallet's own path, since Sphere issues several addresses per seed and the funds may sit at index 1, 2, … Scan behaviour itself is unchanged: the "found" criterion is still balance > dustThreshold.

Compatibility

Old non-BIP32 (WIF/HMAC) backups are unaffected, and this is asserted rather than assumed. The standard-wallet branch of saveWallet() is untouched; resolveDescriptorPathFromBackup() returns null without a chain code; and a wallet with no chain code stays on HMAC derivation even if a path is forced on it. Pre-existing BIP32 backups still resolve to 84'/1'/0' and yield the same address as before.

Tests

node test_sphere_import.js — 75 assertions, no framework. The functions under test are extracted from index.html at runtime, so the tests exercise the shipped file rather than a copy that can drift.

One of them guards a trap specific to this file: index.html has two scopes inside one <script> — functions indented 8 spaces are top level, those indented 12 live inside the DOMContentLoaded callback. A top-level function calling an inner one throws only when the user clicks, which looks like "the button does nothing" (it happened to saveWallet during this work). The test fails on any such call that is not routed through window.<name>, the pattern window.hexToWIF already uses.

Not in this PR

Found while auditing index.html before starting; each deserves its own branch:

  • Private keys are written to localStorage and IndexedDB in plaintext (saveWalletData()), and the at-rest encryption function is commented out — despite the README stating that stored data is encrypted.
  • Real master keys, a master public key and a chain code are committed to this public repo in the root-level decrypt_*.js / find_jerome_*.js scripts. Worth checking whether those addresses hold funds.
  • submitDebugReport() POSTs to https://unicity-debug-report.dyndns.org:3487 with no confirmation step.
  • extractFromWalletDat() logs the master private key to the browser console.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow webwallet to import Sphere's json

1 participant