From 70399833dc245c53835d534955a2baefd28f8411 Mon Sep 17 00:00:00 2001 From: Adam K Dean Date: Tue, 29 Sep 2026 15:46:24 +0100 Subject: [PATCH 01/12] feat: add claim xe service client and transfer tracking --- src/utils/claim.js | 125 ++++++++++++++++++++++++++++++++++++++++ src/utils/claim.test.js | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/utils/claim.js create mode 100644 src/utils/claim.test.js diff --git a/src/utils/claim.js b/src/utils/claim.js new file mode 100644 index 0000000..462872a --- /dev/null +++ b/src/utils/claim.js @@ -0,0 +1,125 @@ +// 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() + +// 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..4c4c59a --- /dev/null +++ b/src/utils/claim.test.js @@ -0,0 +1,101 @@ +// 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, loadActiveClaim, loadFailedRefs, markClaimFailed, + newXeAddressRegexp, normaliseXeAddress, saveActiveClaim +} from './claim' + +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() + }) +}) From a179a92f0f6504545b29ec10de14cbd725672ab8 Mon Sep 17 00:00:00 2001 From: Adam K Dean Date: Tue, 29 Sep 2026 15:46:24 +0100 Subject: [PATCH 02/12] feat: add claim xe modal with progress tracking --- src/components/tx/ClaimModal.vue | 636 +++++++++++++++++++++++++++++++ 1 file changed, 636 insertions(+) create mode 100644 src/components/tx/ClaimModal.vue diff --git a/src/components/tx/ClaimModal.vue b/src/components/tx/ClaimModal.vue new file mode 100644 index 0000000..8396c9b --- /dev/null +++ b/src/components/tx/ClaimModal.vue @@ -0,0 +1,636 @@ + + + + + From 1bfec38ef4e4663535b9582f45fb0e8db49228d2 Mon Sep 17 00:00:00 2001 From: Adam K Dean Date: Tue, 29 Sep 2026 15:46:24 +0100 Subject: [PATCH 03/12] feat: add claim xe button to account panel --- src/components/AccountPanel.vue | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) 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 -