diff --git a/package-lock.json b/package-lock.json index 1fc5e33..2c2b1ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,9 @@ "@heroicons/vue": "^1.0.1", "@metamask/detect-provider": "^1.2.0", "@metamask/onboarding": "^1.0.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", + "@scure/bip39": "^2.4.0", "@vuelidate/core": "^2.0.0-alpha.16", "@vuelidate/validators": "^2.0.0-alpha.13", "chart.js": "^3.7.1", @@ -1600,6 +1603,33 @@ "bowser": "^2.9.0" } }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1967,6 +1997,18 @@ "win32" ] }, + "node_modules/@scure/bip39": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.4.0.tgz", + "integrity": "sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -7744,6 +7786,19 @@ "bowser": "^2.9.0" } }, + "@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "requires": { + "@noble/hashes": "2.4.0" + } + }, + "@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -7923,6 +7978,14 @@ "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "optional": true }, + "@scure/bip39": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.4.0.tgz", + "integrity": "sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==", + "requires": { + "@noble/hashes": "2.4.0" + } + }, "@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/package.json b/package.json index b29c804..6070ee0 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "@heroicons/vue": "^1.0.1", "@metamask/detect-provider": "^1.2.0", "@metamask/onboarding": "^1.0.0", + "@noble/curves": "^2.4.0", + "@noble/hashes": "^2.4.0", + "@scure/bip39": "^2.4.0", "@vuelidate/core": "^2.0.0-alpha.16", "@vuelidate/validators": "^2.0.0-alpha.13", "chart.js": "^3.7.1", diff --git a/src/components/AccountPanel.vue b/src/components/AccountPanel.vue index 65b27a0..5498bf3 100644 --- a/src/components/AccountPanel.vue +++ b/src/components/AccountPanel.vue @@ -24,7 +24,7 @@ Create Stake -
+
+ +
+ @@ -69,6 +77,7 @@ + + diff --git a/src/components/tx/XeAddressModal.vue b/src/components/tx/XeAddressModal.vue new file mode 100644 index 0000000..59ac9b7 --- /dev/null +++ b/src/components/tx/XeAddressModal.vue @@ -0,0 +1,241 @@ + + + + + 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" + } + ] +}