+ Step 1 of 2: write down your recovery phrase
+
+
+
+
+
+
+
+
+ {{ address }}
+
+
+ Its recovery phrase is these 24 words. Write them down in order and keep them safe.
+
+
+
{{ i + 1 }}{{ word }}
+
+
+
+
+ Anyone with these words controls your $XE. If you lose them, nobody can recover your $XE.
+ This wallet does not keep them.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Create an XE address
+ Step 2 of 2: check your recovery phrase
+
+
+
+
Enter these words from your written copy.
+
+
+
+
+ This is not word {{ position + 1 }}. Check your written copy.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/utils/claim.js b/src/utils/claim.js
new file mode 100644
index 0000000..90f6214
--- /dev/null
+++ b/src/utils/claim.js
@@ -0,0 +1,134 @@
+// Copyright (C) 2026 Edge Network Technologies Limited
+// Use of this source code is governed by a GNU GPL-style license
+// that can be found in the LICENSE.md file. All rights reserved.
+
+// Claim XE: client for the claims service (edge/xe-claim) and the logic that
+// follows a claim transfer on the Edge chain.
+
+const CLAIMS_API_URL = import.meta.env.VITE_CLAIMS_API_URL
+
+export const claimsEnabled = () => !!CLAIMS_API_URL
+
+async function request(path, options = {}) {
+ const res = await fetch(`${CLAIMS_API_URL}${path}`, {
+ ...options,
+ headers: { 'content-type': 'application/json' }
+ })
+ const body = await res.json().catch(() => ({}))
+ if (!res.ok) throw new Error(body.error || `Claims service error (HTTP ${res.status}).`)
+ return body
+}
+
+export const fetchClaimsInfo = () => request('/info')
+
+export const createClaim = (edgeAddress, xeAddress, amount) => request('/claims', {
+ method: 'POST',
+ body: JSON.stringify({ edgeAddress, xeAddress, amount })
+})
+
+export const fetchClaim = ref => request(`/claims/${ref}`)
+
+export const fetchClaims = async address => (await request(`/claims?address=${address}`)).results
+
+// New XE address: 64 hex, no prefix. Not an Edge address (xe_...).
+export const newXeAddressRegexp = /^[0-9a-f]{64}$/
+
+export const normaliseXeAddress = address => address.trim().toLowerCase()
+
+// Joins the last two words with a non-breaking space, so a message that wraps
+// never leaves one word alone on its last line.
+export const keepLastWordsTogether = text => text.trim().replace(/\s+(\S+)$/, '\u00a0$1')
+
+// Stages of a claim transfer, in order.
+export const STAGES = ['posted', 'in_block', 'confirmed', 'received']
+
+// Where a claim transfer is, from one round of checks:
+// chainTx GET /transaction/:hash on the chain, or null when not found
+// pending the hash is in the sender's pending transactions
+// walletNonce the sender's confirmed nonce
+// txNonce the nonce the claim transfer was signed with
+// claim the claim from the claims service, or null
+// required confirmations the claims service waits for
+// The chain can accept a transfer and then drop it. A transfer that is
+// neither pending nor in a block, while its nonce is still unused, is
+// 'missing'; the caller treats repeated misses as a failed claim.
+export function claimProgress({ chainTx, pending, walletNonce, txNonce, claim, required }) {
+ if (claim && claim.status === 'received') return { stage: 'received', confirmations: required, percent: 100 }
+
+ // The chain is the evidence: a transfer in a block is on its way, whatever
+ // the claims service says about the claim's expiry.
+ if (chainTx) {
+ const confirmations = chainTx.confirmations
+ if (confirmations >= required) return { stage: 'confirmed', confirmations, percent: 90 }
+ return { stage: 'in_block', confirmations, percent: 30 + Math.floor(55 * confirmations / required) }
+ }
+
+ if (claim && claim.status === 'expired') return { stage: 'failed', confirmations: 0, percent: 0 }
+
+ if (pending || walletNonce > txNonce) return { stage: 'posted', confirmations: 0, percent: 15 }
+ return { stage: 'missing', confirmations: 0, percent: 15 }
+}
+
+// Misses in a row before a claim counts as failed. One miss can be a transfer
+// moving from the mempool into a block between two requests.
+export const MISSES_TO_FAIL = 3
+
+// The claim in progress is kept in the browser, per Edge address, so the
+// modal can follow it again after the page is closed.
+const storageKey = address => `claim-xe:${address}`
+
+export function saveActiveClaim(address, claim) {
+ try {
+ localStorage.setItem(storageKey(address), JSON.stringify(claim))
+ }
+ catch (err) {
+ console.error('Could not save the claim in progress:', err)
+ }
+}
+
+export function loadActiveClaim(address) {
+ try {
+ return JSON.parse(localStorage.getItem(storageKey(address)))
+ }
+ catch {
+ return null
+ }
+}
+
+export function clearActiveClaim(address) {
+ try {
+ localStorage.removeItem(storageKey(address))
+ }
+ catch {
+ // Storage unavailable: nothing to clear.
+ }
+}
+
+// References whose transfer this browser saw fail. The claims service only
+// calls such a claim expired after its TTL, so the list shows it failed now.
+const failedKey = address => `claim-xe-failed:${address}`
+
+export function loadFailedRefs(address) {
+ try {
+ return JSON.parse(localStorage.getItem(failedKey(address))) || []
+ }
+ catch {
+ return []
+ }
+}
+
+export function markClaimFailed(address, ref) {
+ try {
+ const refs = loadFailedRefs(address).filter(r => r !== ref)
+ localStorage.setItem(failedKey(address), JSON.stringify([ref, ...refs].slice(0, 20)))
+ }
+ catch (err) {
+ console.error('Could not save the failed claim:', err)
+ }
+}
+
+// Status for the claims list: failed when the service says expired, or when
+// the transfer failed here and the service has not seen one.
+export function claimFailed(claim, failedRefs) {
+ return claim.status === 'expired' || (claim.status === 'awaiting_transfer' && failedRefs.includes(claim.ref))
+}
diff --git a/src/utils/claim.test.js b/src/utils/claim.test.js
new file mode 100644
index 0000000..92cfc23
--- /dev/null
+++ b/src/utils/claim.test.js
@@ -0,0 +1,117 @@
+// Copyright (C) 2026 Edge Network Technologies Limited
+// Use of this source code is governed by a GNU GPL-style license
+// that can be found in the LICENSE.md file. All rights reserved.
+
+import {
+ claimFailed, claimProgress, clearActiveClaim, keepLastWordsTogether, loadActiveClaim, loadFailedRefs,
+ markClaimFailed, newXeAddressRegexp, normaliseXeAddress, saveActiveClaim
+} from './claim'
+import { describe, expect, it } from 'vitest'
+
+const base = { chainTx: null, pending: false, walletNonce: 4, txNonce: 4, claim: null, required: 10 }
+
+describe('claimProgress', () => {
+ it('is posted while the transfer is pending', () => {
+ expect(claimProgress({ ...base, pending: true }).stage).toBe('posted')
+ })
+
+ it('counts confirmations once the transfer is in a block', () => {
+ const at0 = claimProgress({ ...base, chainTx: { confirmations: 0 } })
+ const at5 = claimProgress({ ...base, chainTx: { confirmations: 5 } })
+ expect(at0).toMatchObject({ stage: 'in_block', confirmations: 0 })
+ expect(at5).toMatchObject({ stage: 'in_block', confirmations: 5 })
+ expect(at5.percent).toBeGreaterThan(at0.percent)
+ })
+
+ it('is confirmed at the required confirmations', () => {
+ expect(claimProgress({ ...base, chainTx: { confirmations: 10 } }).stage).toBe('confirmed')
+ expect(claimProgress({ ...base, chainTx: { confirmations: 25 } }).stage).toBe('confirmed')
+ })
+
+ it('is received once the claims service has it, whatever the chain says', () => {
+ const received = claimProgress({ ...base, claim: { status: 'received' } })
+ expect(received).toMatchObject({ stage: 'received', percent: 100 })
+ })
+
+ it('is missing when the transfer is nowhere and its nonce is unused', () => {
+ expect(claimProgress(base).stage).toBe('missing')
+ })
+
+ it('is not missing when the nonce is used: the transfer is on its way into a block', () => {
+ expect(claimProgress({ ...base, walletNonce: 5 }).stage).toBe('posted')
+ })
+
+ it('fails when the claims service reports the claim expired', () => {
+ expect(claimProgress({ ...base, claim: { status: 'expired' } }).stage).toBe('failed')
+ })
+
+ it('trusts the chain over an expired claim', () => {
+ const progress = claimProgress({ ...base, chainTx: { confirmations: 2 }, claim: { status: 'expired' } })
+ expect(progress.stage).toBe('in_block')
+ })
+
+ it('keeps the percentage in order through the stages', () => {
+ const percents = [
+ claimProgress({ ...base, pending: true }),
+ claimProgress({ ...base, chainTx: { confirmations: 0 } }),
+ claimProgress({ ...base, chainTx: { confirmations: 9 } }),
+ claimProgress({ ...base, chainTx: { confirmations: 10 } }),
+ claimProgress({ ...base, claim: { status: 'received' } })
+ ].map(p => p.percent)
+ expect([...percents].sort((a, b) => a - b)).toEqual(percents)
+ })
+})
+
+describe('new XE addresses', () => {
+ it('accepts 64 hex after trimming and lowercasing', () => {
+ expect(newXeAddressRegexp.test(normaliseXeAddress(` ${'AB'.repeat(32)} `))).toBe(true)
+ })
+
+ it('rejects Edge addresses and short values', () => {
+ expect(newXeAddressRegexp.test(normaliseXeAddress('xe_0cba2b664C9672860Bcbf51d7Ed022Cb9E007563'))).toBe(false)
+ expect(newXeAddressRegexp.test('ab'.repeat(31))).toBe(false)
+ })
+})
+
+describe('failed claims', () => {
+ it('remembers failed references per address', () => {
+ markClaimFailed('xe_one', 'r1')
+ markClaimFailed('xe_one', 'r2')
+ markClaimFailed('xe_one', 'r1')
+ expect(loadFailedRefs('xe_one')).toEqual(['r1', 'r2'])
+ expect(loadFailedRefs('xe_two')).toEqual([])
+ })
+
+ it('shows a claim failed when expired, or awaiting with a transfer that failed here', () => {
+ expect(claimFailed({ status: 'expired', ref: 'x' }, [])).toBe(true)
+ expect(claimFailed({ status: 'awaiting_transfer', ref: 'x' }, ['x'])).toBe(true)
+ expect(claimFailed({ status: 'awaiting_transfer', ref: 'x' }, [])).toBe(false)
+ expect(claimFailed({ status: 'received', ref: 'x' }, ['x'])).toBe(false)
+ })
+})
+
+describe('active claim storage', () => {
+ it('saves, loads and clears the claim in progress per address', () => {
+ const claim = { ref: 'a'.repeat(64), hash: 'b'.repeat(64), nonce: 3 }
+ saveActiveClaim('xe_one', claim)
+ expect(loadActiveClaim('xe_one')).toEqual(claim)
+ expect(loadActiveClaim('xe_two')).toBeNull()
+ clearActiveClaim('xe_one')
+ expect(loadActiveClaim('xe_one')).toBeNull()
+ })
+})
+
+describe('keepLastWordsTogether', () => {
+ it('joins the last two words with a non-breaking space', () => {
+ expect(keepLastWordsTogether('too many open claims for this address')).toBe('too many open claims for this\u00a0address')
+ })
+
+ it('ignores trailing space', () => {
+ expect(keepLastWordsTogether('try again later ')).toBe('try again\u00a0later')
+ })
+
+ it('leaves a single word as it is', () => {
+ expect(keepLastWordsTogether('Failed')).toBe('Failed')
+ expect(keepLastWordsTogether('')).toBe('')
+ })
+})
diff --git a/src/utils/xe-account.js b/src/utils/xe-account.js
new file mode 100644
index 0000000..d8518b5
--- /dev/null
+++ b/src/utils/xe-account.js
@@ -0,0 +1,44 @@
+// Copyright (C) 2026 Edge Network Technologies Limited
+// Use of this source code is governed by a GNU GPL-style license
+// that can be found in the LICENSE.md file. All rights reserved.
+
+// New XE address for Claim XE. XE is the new chain (xeprotocol/core), not the
+// Edge chain: its addresses are 64 lowercase hex with no prefix.
+//
+// The XE recovery phrase (core #1005) is the 32-byte ed25519 seed written as
+// 24 BIP39 English words: BIP39's entropy-to-mnemonic encoding applied to the
+// seed itself. No PBKDF2, passphrase or derivation path: the words are the
+// seed. The address is sha256("xe/account/v1" || ed25519 public key).
+// xe-recovery-phrase-vectors.json is a copy of the core vectors.
+
+import { ed25519 } from '@noble/curves/ed25519.js'
+import { entropyToMnemonic } from '@scure/bip39'
+import { sha256 } from '@noble/hashes/sha2.js'
+import { wordlist } from '@scure/bip39/wordlists/english.js'
+import { bytesToHex, concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils.js'
+
+const ACCOUNT_DOMAIN = utf8ToBytes('xe/account/v1')
+
+export const phraseFromSeed = seed => entropyToMnemonic(seed, wordlist)
+
+export const publicKeyFromSeed = seed => ed25519.getPublicKey(seed)
+
+export const addressFromPublicKey = publicKey => bytesToHex(sha256(concatBytes(ACCOUNT_DOMAIN, publicKey)))
+
+// A new XE account from a random seed. The seed is not kept: the words are the
+// only copy.
+export function newXeAccount() {
+ const seed = randomBytes(32)
+ return {
+ words: phraseFromSeed(seed).split(' '),
+ address: addressFromPublicKey(publicKeyFromSeed(seed))
+ }
+}
+
+// Distinct word positions (0-based, in order) to ask back after the user has
+// written the phrase down.
+export function pickCheckPositions(count, total) {
+ const positions = new Set()
+ while (positions.size < count) positions.add(Math.floor(Math.random() * total))
+ return [...positions].sort((a, b) => a - b)
+}
diff --git a/src/utils/xe-account.test.js b/src/utils/xe-account.test.js
new file mode 100644
index 0000000..a05ceee
--- /dev/null
+++ b/src/utils/xe-account.test.js
@@ -0,0 +1,62 @@
+// Copyright (C) 2026 Edge Network Technologies Limited
+// Use of this source code is governed by a GNU GPL-style license
+// that can be found in the LICENSE.md file. All rights reserved.
+
+import { mnemonicToEntropy } from '@scure/bip39'
+import { sha256 } from '@noble/hashes/sha2.js'
+import vectors from './xe-recovery-phrase-vectors.json'
+import { wordlist } from '@scure/bip39/wordlists/english.js'
+import {
+ addressFromPublicKey, newXeAccount, phraseFromSeed, pickCheckPositions, publicKeyFromSeed
+} from './xe-account'
+import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'
+import { describe, expect, it } from 'vitest'
+
+// The vectors file is a copy of xeprotocol/core core/testdata/recovery-phrase-vectors.json.
+describe('XE recovery phrase vectors', () => {
+ it('uses the BIP39 English wordlist that core pins', () => {
+ expect(bytesToHex(sha256(utf8ToBytes(`${wordlist.join('\n')}\n`)))).toBe(vectors.wordlist_sha256)
+ })
+
+ it('encodes each BIP39 32-byte entropy vector', () => {
+ for (const v of vectors.bip39) {
+ expect(phraseFromSeed(hexToBytes(v.entropy))).toBe(v.mnemonic)
+ }
+ })
+
+ it('derives the phrase, public key and address of each XE vector', () => {
+ for (const v of vectors.xe) {
+ const seed = hexToBytes(v.seed)
+ const publicKey = publicKeyFromSeed(seed)
+ expect(phraseFromSeed(seed)).toBe(v.phrase)
+ expect(bytesToHex(publicKey)).toBe(v.public_key)
+ expect(addressFromPublicKey(publicKey)).toBe(v.address)
+ }
+ })
+})
+
+describe('newXeAccount', () => {
+ it('returns 24 words whose seed derives the returned address', () => {
+ const { address, words } = newXeAccount()
+ expect(words).toHaveLength(24)
+ expect(address).toMatch(/^[0-9a-f]{64}$/)
+ const seed = mnemonicToEntropy(words.join(' '), wordlist)
+ expect(addressFromPublicKey(publicKeyFromSeed(seed))).toBe(address)
+ })
+
+ it('returns a new account each time', () => {
+ expect(newXeAccount().address).not.toBe(newXeAccount().address)
+ })
+})
+
+describe('pickCheckPositions', () => {
+ it('picks distinct positions in order, inside the phrase', () => {
+ for (let i = 0; i < 50; i++) {
+ const positions = pickCheckPositions(3, 24)
+ expect(positions).toHaveLength(3)
+ expect(new Set(positions).size).toBe(3)
+ expect([...positions].sort((a, b) => a - b)).toEqual(positions)
+ positions.forEach(p => expect(p >= 0 && p < 24).toBe(true))
+ }
+ })
+})
diff --git a/src/utils/xe-recovery-phrase-vectors.json b/src/utils/xe-recovery-phrase-vectors.json
new file mode 100644
index 0000000..c5fa223
--- /dev/null
+++ b/src/utils/xe-recovery-phrase-vectors.json
@@ -0,0 +1,70 @@
+{
+ "description": "XE recovery phrase test vectors (xeprotocol/core#1005). An XE recovery phrase is the BIP39 entropy-to-mnemonic encoding of the 32-byte ed25519 seed with the BIP39 English wordlist: seed || first 8 bits of SHA-256(seed) gives 264 bits, read most significant bit first as 24 word indexes of 11 bits. Exactly 24 words. No PBKDF2, no passphrase, no derivation path: the words are the seed. bip39: the official BIP39 English vectors with 32-byte entropy, from trezor/python-mnemonic vectors.json, entropy and mnemonic only (the PBKDF2 seed and xprv columns do not apply). xe: seed (hex), phrase, public_key (hex ed25519 public key from the seed, RFC 8032) and address (hex SHA-256 of the UTF-8 bytes 'xe/account/v1' followed by the 32-byte public key).",
+ "wordlist_sha256": "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda",
+ "bip39": [
+ {
+ "entropy": "0000000000000000000000000000000000000000000000000000000000000000",
+ "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"
+ },
+ {
+ "entropy": "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
+ "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title"
+ },
+ {
+ "entropy": "8080808080808080808080808080808080808080808080808080808080808080",
+ "mnemonic": "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless"
+ },
+ {
+ "entropy": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "mnemonic": "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote"
+ },
+ {
+ "entropy": "68a79eaca2324873eacc50cb9c6eca8cc68ea5d936f98787c60c7ebc74e6ce7c",
+ "mnemonic": "hamster diagram private dutch cause delay private meat slide toddler razor book happy fancy gospel tennis maple dilemma loan word shrug inflict delay length"
+ },
+ {
+ "entropy": "9f6a2878b2520799a44ef18bc7df394e7061a224d2c33cd015b157d746869863",
+ "mnemonic": "panda eyebrow bullet gorilla call smoke muffin taste mesh discover soft ostrich alcohol speed nation flash devote level hobby quick inner drive ghost inside"
+ },
+ {
+ "entropy": "066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad",
+ "mnemonic": "all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform"
+ },
+ {
+ "entropy": "f585c11aec520db57dd353c69554b21a89b20fb0650966fa0a9d6f74fd989d8f",
+ "mnemonic": "void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold"
+ }
+ ],
+ "xe": [
+ {
+ "seed": "0000000000000000000000000000000000000000000000000000000000000000",
+ "phrase": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
+ "public_key": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29",
+ "address": "4ac0d6d4f0bd50354b93324c950096f6e0de5f91e42dac66583756afd50852de"
+ },
+ {
+ "seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "phrase": "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
+ "public_key": "76a1592044a6e4f511265bca73a604d90b0529d1df602be30a19a9257660d1f5",
+ "address": "b25fe45d4afa568e72559a9e0bcf727c44dbdde8ab591df1f53fdd908cd73dbb"
+ },
+ {
+ "seed": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
+ "phrase": "abandon amount liar amount expire adjust cage candy arch gather drum bullet absurd math era live bid rhythm alien crouch range attend journey unaware",
+ "public_key": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
+ "address": "338c76e03f0a8dd21192808ba1a11879b2202591bf53f4338070119f4f54a0e1"
+ },
+ {
+ "seed": "7a11413e0111030869fb5d1e434ef96110be6dbb0fb160064b3fa7a36a5f35bf",
+ "phrase": "key meat exhibit acquire awake loud pony hill bundle bounce salad season armor cycle umbrella wage gas crane soup exercise cute convince street trophy",
+ "public_key": "42aa7e1f7b83cf10b730a4ac7e11c8c4dda9321b60207ee8a9649932cada7319",
+ "address": "1cc821b6fedffceeac924054c97a3185ab835c498da225cbbd7bc89f1945a769"
+ },
+ {
+ "seed": "1c9813e44d6cfe05cd8d55a442897c56091e03aa6744c82cc16e33383d3a054e",
+ "phrase": "broken scene weekend onion soup actual curtain primary picture begin gallery project music achieve praise trigger sight gravity fortune grid long other benefit special",
+ "public_key": "a0ccc1be08b3b369c3b5b2bc8e47a61a1cb2cc6fa5662a5882ba09065cf1558c",
+ "address": "cadb731c0c153a76bd503702f7c2f51026623d2e3b31b51981cbe6873f0bb26b"
+ }
+ ]
+}